diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbb0b8d79..0a57e0e6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,12 @@ concurrency: cancel-in-progress: false jobs: + # Regenerates contributors/contributors.json for the release commit; every build bundles it. + credits: + permissions: + contents: read + pull-requests: read + uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main git-sources: name: Git dependencies come from storytold runs-on: ubuntu-latest @@ -60,7 +66,7 @@ jobs: PY release: - needs: git-sources + needs: [git-sources, credits] permissions: contents: write packages: write @@ -72,6 +78,7 @@ jobs: craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} + contributors-artifact: ${{ needs.credits.outputs.artifact-name }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} diff --git a/Cargo.lock b/Cargo.lock index 6f92cb310..72d591b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2978,8 +2978,7 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" [[package]] name = "linesweeper" version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18" +source = "git+https://github.com/storytold/craft-libs?rev=481f66d287b2a06845f3667a19daf35f0fe958ed#481f66d287b2a06845f3667a19daf35f0fe958ed" dependencies = [ "arrayvec", "kurbo 0.13.1", @@ -6265,6 +6264,7 @@ dependencies = [ "vectorcraft-eps", "vectorcraft-format", "vectorcraft-geom", + "vectorcraft-js", "vectorcraft-metafile", "vectorcraft-pathops", "vectorcraft-pdf", @@ -6283,6 +6283,7 @@ dependencies = [ name = "vectorcraft-eps" version = "0.21.0" dependencies = [ + "craft-pdf", "fax", "flate2", "hayro-ccitt", @@ -6332,6 +6333,14 @@ dependencies = [ "vectorcraft-testkit", ] +[[package]] +name = "vectorcraft-js" +version = "0.21.0" +dependencies = [ + "proptest", + "unicode-general-category", +] + [[package]] name = "vectorcraft-mcp" version = "0.21.0" @@ -6413,8 +6422,10 @@ dependencies = [ name = "vectorcraft-plugins" version = "0.21.0" dependencies = [ + "log", "serde", "serde_json", + "sha2", "thiserror 2.0.21", "vectorcraft-color", "vectorcraft-doc", diff --git a/Cargo.toml b/Cargo.toml index 924a1a68d..a3e3e9890 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ vectorcraft-metafile = { path = "crates/metafile" } vectorcraft-plugins = { path = "crates/plugins" } vectorcraft-affinity = { path = "crates/affinity" } vectorcraft-scriptcheck = { path = "crates/scriptcheck" } +vectorcraft-js = { path = "crates/js" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -153,6 +154,7 @@ hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637 hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } krilla = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +linesweeper = { git = "https://github.com/storytold/craft-libs", rev = "481f66d287b2a06845f3667a19daf35f0fe958ed" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index ea3c311a3..c5c5511ba 100644 --- a/NOTICE +++ b/NOTICE @@ -20,10 +20,15 @@ list (author, source, license) is in ASSETS.md. (Ubuntu Font Licence 1.0), Hack (MIT), Noto Emoji (SIL Open Font License 1.1) and emoji-icon-font (MIT). Their license texts ship with that crate. -Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are +Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, egui, eframe, …) are fetched through crates.io under their own permissive licenses (mostly MIT and/or Apache-2.0; also BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0). +linesweeper 0.5.0 (MIT OR Apache-2.0) is fetched from https://github.com/storytold/craft-libs at +revision 481f66d287b2a06845f3667a19daf35f0fe958ed. The copy in craft-libs' vendor/linesweeper is +the crates.io package with no file changed, and it keeps the package's LICENSE-MIT and +LICENSE-APACHE. + JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is based in part on the work of the Independent JPEG Group. diff --git a/README.md b/README.md index 79d88841e..d71e5b23a 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** -- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes leaves out live effects and writes symbols and live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on 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 (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); diff --git a/ROADMAP.md b/ROADMAP.md index 68b0b2d2a..0f9267e0e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ and the work list in [`docs/gaps.md`](docs/gaps.md). **Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance, type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that -leaves out live effects and writes symbols and live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on 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 (see [docs/file-format-parity.md](docs/file-format-parity.md)), and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 5d9eb457f..3d9fef45c 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -125,6 +125,13 @@ impl App { } open_files(&mut self.app, files); } + let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor()); + if let Some(w) = &self.winit_window + && window::monitors_due(self.monitors_listed, now, zoom) + { + self.app.services.dock_monitors = window::dock_monitors(w, zoom); + self.monitors_listed = Some((now, zoom)); + } self.app.tick(ctx); if self.pending_maximize { if let Some(w) = &self.winit_window { diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 978308b9e..187c42cde 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -298,13 +298,16 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti // up its Format menu before the sheet begins (#866); rfd shows the others. PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) { // The dialog thread blocks here until the sheet answers. - Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }), - None => { + Ok(mac_save_format::Begin::Shown(picked)) => Box::pin(async move { picked.recv().unwrap_or_default() }), + Ok(mac_save_format::Begin::DeferToRfd) => { // Nothing is added to the sheet once rfd has begun it: an accessory view set then // made recent macOS versions dismiss the sheet at once (#1800). let picked = dialog.save_file(); Box::pin(async move { picked.await.iter().map(path).collect() }) } + // AppKit returned nil for the panel. rfd uses the same `+savePanel` and would panic + // on that nil; treat the pick as cancelled instead (#5980). + Err(()) => Box::pin(async move { Vec::new() }), }, PickRequest::Folder => { let picked = dialog.pick_folder(); diff --git a/apps/vectorcraft/src/mac_save_format.rs b/apps/vectorcraft/src/mac_save_format.rs index fe2097157..17bad11ef 100644 --- a/apps/vectorcraft/src/mac_save_format.rs +++ b/apps/vectorcraft/src/mac_save_format.rs @@ -21,7 +21,7 @@ use std::sync::mpsc::{Receiver, channel}; use block2::RcBlock; use objc2::rc::{Retained, Weak}; -use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; +use objc2::{ClassType, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView}; use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; @@ -108,21 +108,42 @@ fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) { panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types))); } -/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the -/// receiver with the path picked (none when canceled). `None` when the request has fewer than two -/// named file types, or `window` has no AppKit window; nothing is shown then. -pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option>> { - let mtm = MainThreadMarker::new()?; +/// How [`begin`] decided to show a save sheet. +#[derive(Debug)] +pub enum Begin { + /// The Format-menu sheet is open; wait on the receiver for the path (empty when canceled). + Shown(Receiver>), + /// Fewer than two named file types: the caller should show an rfd panel instead. + DeferToRfd, +} + +/// Show the save panel for `pick` as a sheet on `window`, with a Format menu. +/// +/// `Ok(Begin::Shown(…))` when the sheet is open. `Ok(Begin::DeferToRfd)` when the request has +/// fewer than two named file types (or no AppKit window yet) so rfd should show the panel. +/// `Err(())` when AppKit returned no panel: the caller must not call rfd, whose `+savePanel` +/// panics on the same nil (#5980). +pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Result { + let Some(mtm) = MainThreadMarker::new() else { + return Ok(Begin::DeferToRfd); + }; let filters = menu_formats(&pick.filters); if filters.is_empty() { - return None; + return Ok(Begin::DeferToRfd); } - let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None }; + let Some(RawWindowHandle::AppKit(handle)) = window.window_handle().ok().map(|h| h.as_raw()) else { + return Ok(Begin::DeferToRfd); + }; // SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the // window is open, and winit hands out the handle only while it is. let view = unsafe { handle.ns_view.cast::().as_ref() }; - let parent = view.window()?; - let panel = NSSavePanel::savePanel(mtm); + let Some(parent) = view.window() else { + return Ok(Begin::DeferToRfd); + }; + let Some(panel) = new_panel(mtm) else { + log::error!("macOS save panel unavailable: AppKit returned nil for NSSavePanel (#5980)"); + return Err(()); + }; let selected = initial_format(&pick.name, &filters); set_allowed_types(&panel, &filters, selected); if let Some(folder) = &pick.folder { @@ -145,7 +166,23 @@ pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option Option> { + // SAFETY: designated initializer for a bare save panel; returns nil when AppKit refuses. + let panel: Option> = unsafe { msg_send![NSSavePanel::alloc(mtm), init] }; + if panel.is_some() { + return panel; + } + // SAFETY: class method; returns an autoreleased panel or nil. Typed as Option so nil is not a + // panic (unlike `NSSavePanel::savePanel`). + let panel: Option> = unsafe { msg_send![NSSavePanel::class(), savePanel] }; + panel } /// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file. @@ -234,6 +271,16 @@ mod tests { assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]); } + /// A nil AppKit panel must be `None`, not a panic. `NSSavePanel::savePanel` used to panic here + /// (#5980); `new_panel` types the same message send as `Option`. + #[test] + fn a_null_save_panel_pointer_is_none_not_a_panic() { + let null: *mut NSSavePanel = std::ptr::null_mut(); + // SAFETY: retaining a null object pointer is defined to yield None. + let panel: Option> = unsafe { Retained::retain(null) }; + assert!(panel.is_none()); + } + /// Each entry of the Save As menu gives a name the save command reads as that entry's format. #[test] fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() { diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 0078dd4b4..8019276ec 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -111,6 +111,8 @@ struct App { pending_maximize: bool, /// Frames left to retry [`window::apply_pending_maximize`]. maximize_tries: u8, + /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). + monitors_listed: Option<(f64, f32)>, } /// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s. @@ -505,6 +507,7 @@ fn main() -> std::process::ExitCode { winit_window, pending_maximize, maximize_tries: 8, + monitors_listed: None, })) }), ) diff --git a/apps/vectorcraft/src/window.rs b/apps/vectorcraft/src/window.rs index abe60f69f..c969e5226 100644 --- a/apps/vectorcraft/src/window.rs +++ b/apps/vectorcraft/src/window.rs @@ -3,6 +3,7 @@ //! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved //! onto a monitor that is still connected. +use vectorcraft_ui_egui::DockMonitor; use vectorcraft_ui_egui::state::WindowGeometry; use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; @@ -14,6 +15,9 @@ pub const MIN_SIZE: [f32; 2] = [800.0, 500.0]; /// say how much of the screen they take): a monitor with less room opens it maximized. const SCREEN_ROOM: [f32; 2] = [64.0, 128.0]; +/// Seconds between readings of the monitor list, which picks up a monitor connected while the app runs. +const MONITORS_EVERY: f64 = 1.0; + /// A monitor: its top-left corner and size in physical pixels, and its scale factor. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Monitor { @@ -67,6 +71,15 @@ impl Monitor { i32::try_from(want.max(lo).min(hi)).unwrap_or_default() }) } + + /// This monitor for floating panels, in the points egui reports for a window on it: physical + /// pixels divided by the monitor's scale factor and the UI zoom. + fn docking(&self, name: Option, zoom: f32) -> DockMonitor { + let scale = self.scale * zoom; + let [x, y] = self.pos; + let [w, h] = self.size; + DockMonitor { name, rect: [x, y, w, h].map(|v| v as f32 / scale), scale } + } } /// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else @@ -109,6 +122,33 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] { std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0)) } +/// Whether to list the monitors again: on the first frame, when the UI zoom changed, and at most +/// every [`MONITORS_EVERY`] seconds while the app draws. `listed` holds the last list's time and zoom. +pub fn monitors_due(listed: Option<(f64, f32)>, now: f64, zoom: f32) -> bool { + listed.is_none_or(|(at, listed_zoom)| listed_zoom != zoom || now - at >= MONITORS_EVERY) +} + +/// The connected monitors for floating panels (`Services::dock_monitors`). +pub fn dock_monitors(window: &winit::window::Window, zoom: f32) -> Vec { + let monitors: Vec<_> = window.available_monitors().map(|m| (Monitor::new(m.position(), m.size(), m.scale_factor()), m.name())).collect(); + docking_list(&monitors, zoom, cfg!(target_os = "macos")) +} + +/// The list for floating panels, empty when `monitors` have different scales and `shared_points` is +/// false. macOS has one desktop of points. Elsewhere a new window multiplies its saved position by the +/// scale of the monitor it is created on, which can put a window saved on another scale off every +/// monitor. With an empty list craft-ui opens the window on the main window's monitor. +/// +/// A name that two monitors share is left out, so craft-ui finds those monitors by their rects: macOS +/// can give two displays of one model the same name. +fn docking_list(monitors: &[(Monitor, Option)], zoom: f32, shared_points: bool) -> Vec { + if !shared_points && monitors.windows(2).any(|pair| pair[0].0.scale != pair[1].0.scale) { + return Vec::new(); + } + let unique = |name: &String| monitors.iter().filter(|(_, other)| other.as_ref() == Some(name)).count() == 1; + monitors.iter().map(|(monitor, name)| monitor.docking(name.clone().filter(unique), zoom)).collect() +} + /// Size and place the new window (eframe shows it after drawing the first frame) and return its /// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run /// until the host reports maximized (fixes #3635). @@ -204,6 +244,51 @@ mod tests { assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE); } + #[test] + fn floating_panels_get_monitors_in_the_points_a_window_on_them_reports() { + let dock = |m: Monitor, zoom| m.docking(Some("m".into()), zoom); + let plain = dock(monitor(1920, 0, 1920, 1080, 1.0), 1.0); + assert_eq!(plain, DockMonitor { name: Some("m".into()), rect: [1920.0, 0.0, 1920.0, 1080.0], scale: 1.0 }); + // A window at (3000, 200) on this monitor reports (1500, 100). + let scaled = dock(monitor(1920, -200, 3840, 2160, 2.0), 1.0); + assert_eq!(scaled, DockMonitor { name: Some("m".into()), rect: [960.0, -100.0, 1920.0, 1080.0], scale: 2.0 }); + // The UI zoom divides what egui reports as well. + let zoomed = dock(monitor(-2560, 0, 2560, 1440, 1.0), 1.25); + assert_eq!(zoomed, DockMonitor { name: Some("m".into()), rect: [-2048.0, 0.0, 2048.0, 1152.0], scale: 1.25 }); + } + + #[test] + fn monitors_of_different_scales_are_listed_only_on_a_desktop_of_shared_points() { + let named = |m: Monitor, name: &str| (m, Some(name.to_string())); + let same = [named(monitor(0, 0, 1920, 1080, 1.5), "a"), named(monitor(1920, 0, 1920, 1080, 1.5), "b")]; + assert_eq!(docking_list(&same, 1.0, false).len(), 2); + let mixed = [named(monitor(0, 0, 3840, 2160, 2.0), "a"), named(monitor(3840, 0, 1920, 1080, 1.0), "b")]; + assert_eq!(docking_list(&mixed, 1.0, false), Vec::new()); + assert_eq!(docking_list(&mixed, 1.0, true).len(), 2); + assert_eq!(docking_list(&[], 1.0, false), Vec::new()); + } + + #[test] + fn a_name_that_two_monitors_share_is_left_out_of_the_list() { + let list = [ + (monitor(0, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())), + (monitor(1920, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())), + (monitor(3840, 0, 1920, 1080, 1.0), Some("Monitor #2".to_string())), + (monitor(5760, 0, 1920, 1080, 1.0), None), + ]; + let names: Vec<_> = docking_list(&list, 1.0, false).into_iter().map(|m| m.name).collect(); + assert_eq!(names, [None, None, Some("Monitor #2".to_string()), None]); + } + + #[test] + fn the_monitor_list_is_read_on_the_first_frame_then_once_a_second_or_on_a_zoom_change() { + assert!(monitors_due(None, 0.0, 1.0)); + assert!(!monitors_due(Some((5.0, 1.0)), 5.0, 1.0)); + assert!(!monitors_due(Some((5.0, 1.0)), 5.5, 1.0)); + assert!(monitors_due(Some((5.0, 1.0)), 6.0, 1.0)); + assert!(monitors_due(Some((5.0, 1.0)), 5.5, 1.25)); + } + #[test] fn the_saved_window_comes_back_where_it_was() { let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)]; diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 093ce531a..448b2c2ba 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -475,6 +475,10 @@ pub struct PatternBrush { pub side_frame: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub side_swatch: Option, + /// Art for the corners where the path turns towards the side tile's top (`-y`); `inner_corner` + /// is the art for the corners where it turns the other way. Both are drawn as the corner that + /// joins a side tile on its right to one below it, and each corner tile is turned so that + /// those two arms meet the sides. #[serde(skip_serializing_if = "Option::is_none")] pub outer_corner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 8bcc8ba2b..11c83d97b 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1,6 +1,6 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2, shapes}; use super::*; @@ -299,6 +299,101 @@ fn pattern_places_corner_tiles() { } } +/// A pattern brush whose corner tiles have a 4 × 4 pt frame and, as art, a 2 × 2 pt square in the +/// top-left quarter of the frame. Corner art joins a side tile on its right to one below it, so +/// that quarter is on the outside of the turn. `inner` and `outer` say which kinds of corner have +/// art; `flips` are the brush's Flip Along and Flip Across. +fn marked_corner_brush(inner: bool, outer: bool, (flip_along, flip_across): (bool, bool)) -> Brush { + let marker = + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -2.0, 2.0, 0.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + let frame = Some(Rect::new(0.0, -2.0, 4.0, 2.0)); + if inner { + (p.inner_corner, p.inner_corner_frame) = (Some(marker.clone()), frame); + } + if outer { + (p.outer_corner, p.outer_corner_frame) = (Some(marker), frame); + } + (p.flip_along, p.flip_across) = (flip_along, flip_across); + } + b +} + +/// Each combination of corner art (both kinds, inner only, outer only) and of the two flips. +fn marked_corner_brushes() -> Vec<(String, Brush)> { + let mut v = vec![]; + for (inner, outer) in [(true, true), (true, false), (false, true)] { + for flips in [(false, false), (true, false), (false, true), (true, true)] { + v.push((format!("inner {inner}, outer {outer}, flips {flips:?}"), marked_corner_brush(inner, outer, flips))); + } + } + v +} + +/// The bounds of the 2 × 2 pt corner markers among `out`, turned or not. +fn marker_bounds(out: &[Node]) -> Vec { + out.iter().filter_map(Node::geometric_bounds).filter(|r| r.width() < 3.0 && r.height() < 3.0).collect() +} + +/// Corner tiles are square to the sides at a right angle and turn 90° with each turn of the path: +/// on a square drawn either way round, each corner's marker fills the quarter of the band's corner +/// outside the turn, with the brush flipped or not and with art for one kind of corner only +/// (#6110). +#[test] +fn pattern_corner_tiles_are_square_to_the_sides() { + let doc = Document::new(1.0, 1.0); + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let mut reversed = BezPath::new(); + reversed.move_to((0.0, 0.0)); + for (x, y) in [(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)] { + reversed.line_to((x, y)); + } + reversed.close_path(); + // The square turns the same way at all four corners, and the reversed square the other way, + // so one draws only inner corner tiles and the other only outer ones. + for (name, brush) in marked_corner_brushes() { + for path in [&sq, &reversed] { + let out = stroke_pieces(&doc, &brush, path, &stroke(1.0)); + let marks = marker_bounds(&out); + assert_eq!(marks.len(), 4, "{name}: one marker a corner"); + for (corner, outside) in SQUARE_CORNERS { + let want = Rect::from_points(corner, outside); + assert!( + marks + .iter() + .any(|r| near(r.x0, want.x0, 1e-6) && near(r.y0, want.y0, 1e-6) && near(r.x1, want.x1, 1e-6) && near(r.y1, want.y1, 1e-6)), + "{name}: the marker at {corner:?} fills {want:?}: {marks:?}" + ); + } + } + } +} + +/// At a corner that is not a right angle, a corner tile splits the difference between the two +/// sides: its marker lies on the line that halves the angle, outside the turn, for sharp and +/// shallow turns either way. Near a hairpin the marker moves smoothly towards the line the path +/// arrives on. +#[test] +fn pattern_corner_tiles_sit_on_the_corners_diagonal() { + let doc = Document::new(1.0, 1.0); + for (name, brush) in marked_corner_brushes() { + for turn in [-179.9_f64, -170.0, -135.0, -60.0, -45.0, 45.0, 60.0, 135.0, 170.0, 179.9] { + let (a, b) = (Vec2::new(1.0, 0.0), Vec2::new(turn.to_radians().cos(), turn.to_radians().sin())); + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((50.0, 0.0)); + bp.line_to(Point::new(50.0, 0.0) + b * 50.0); + let out = stroke_pieces(&doc, &brush, &bp, &stroke(1.0)); + let marks = marker_bounds(&out); + assert_eq!(marks.len(), 1, "{name}, {turn}°: one corner marker"); + let away = a - b; + let want = Point::new(50.0, 0.0) + away / away.hypot() * 2.0_f64.sqrt(); + assert!(marks[0].center().distance(want) < 1e-6, "{name}, {turn}°: the marker is at {:?}, not {want:?}", marks[0].center()); + } + } +} + /// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art. fn auto_corner_brush(outer: Option, inner: Option) -> Brush { let mut b = pattern_brush(PatternFit::Stretch, false); diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index c52f7f630..b34a1cf31 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -879,12 +879,16 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec }; trims.push(None); corner_half = corner_half.max(bb.width() * k / 2.0); - let bis = a + b; - let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() }; - let xf = Affine::translate(t.verts[i].to_vec2()) - * Affine::rotate(bis.y.atan2(bis.x)) - * Affine::scale(k) - * Affine::translate(-bb.center().to_vec2()); + // Inner and outer corner art is the corner that joins a side tile on its right to one + // below it, so its arms point along +x and +y (as the flips leave them). The tile is + // turned so that the line halving its arms lies on the line halving the sides at the + // corner, back along the incoming side and on along the outgoing one: at a right angle + // the arms meet the side tiles, and at other angles the difference is split between + // the two sides (#6110). + let arms = Vec2::new(if pb.flip_along { -1.0 } else { 1.0 }, if pb.flip_across { -1.0 } else { 1.0 }); + let sides = b - a; + let angle = sides.y.atan2(sides.x) - arms.y.atan2(arms.x); + let xf = Affine::translate(t.verts[i].to_vec2()) * Affine::rotate(angle) * Affine::scale(k) * Affine::translate(-bb.center().to_vec2()); let mut n = art.clone(); n.transform(xf, true); out.take(Some(n)); diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index cea213cd6..95abdf2f3 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -231,6 +231,21 @@ impl GenericCmyk { } g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::(); } + // An ink at a bound whose descent direction points out of the box stays where it is; the step moves the other inks. + for p in 0..3 { + if (x[p] <= 0.0 && g[p] < 0.0) || (x[p] >= 1.0 && g[p] > 0.0) { + a[p] = [0.0; 3]; + for row in &mut a { + row[p] = 0.0; + } + a[p][p] = 1.0; + g[p] = 0.0; + } + } + // Every ink is held at a bound, or the gradient is zero: no step can lower the error. + if g == [0.0; 3] { + break; + } let mut improved = false; for _ in 0..8 { let mut al = a; diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 99c16edf4..ecb4c1c59 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -259,6 +259,46 @@ fn generic_cmyk_black_round_trips_with_bpc() { assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); } +/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few +/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024 +/// in all) stays within 0.001 on every ink. +#[test] +fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() { + let c = cms(); + let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]; + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -2000i32..=2000 { + let mut rgb = pink; + rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } + } + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!(lo[0], 0.0, "{lo:?}"); +} + +/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change +/// by a few float steps: sRGB (0, 0, 0.25) with its blue stepped one `f32` at a time over 121 values +/// stays within 0.001 on every ink. +#[test] +fn generic_cmyk_separates_nearby_colors_with_inks_at_full_alike() { + let c = cms(); + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -60i32..=60 { + let blue = f32::from_bits(0.25f32.to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk([0.0, 0.0, blue], Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } + } + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!((lo[0], lo[1]), (1.0, 1.0), "{lo:?}"); +} + #[test] fn lab_colours_round_trip_through_the_cms() { let c = cms_no_bpc(); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 8bca517ee..bc44b7bf4 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -699,6 +699,34 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N /// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an /// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at /// the same place (by character) of both. +/// The run of `of` at fraction `u` of its characters. +fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> { + let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum(); + let target = (u * total as f64) as usize; + let mut seen = 0; + for r in &of.runs { + seen += r.text.chars().count(); + if seen > target { + return Some(r); + } + } + of.runs.last() +} + +/// The fraction of the characters of `runs` at the middle of each run. +fn run_middles(runs: &[crate::text::TextRun]) -> Vec { + let total: usize = runs.iter().map(|r| r.text.chars().count()).sum::().max(1); + let mut start = 0usize; + runs.iter() + .map(|r| { + let len = r.text.chars().count(); + let u = (start as f64 + len as f64 / 2.0) / total as f64; + start += len; + u + }) + .collect() +} + fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) { o.xf = lerp_affine(x.xf, y.xf, t); if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) = @@ -706,27 +734,10 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64 { *f = lerp_path(fx, fy, t); } - // The run of `of` at fraction `u` of its characters. - fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> { - let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum(); - let target = (u * total as f64) as usize; - let mut seen = 0; - for r in &of.runs { - seen += r.text.chars().count(); - if seen > target { - return Some(r); - } - } - of.runs.last() - } - let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::().max(1); - let mut start = 0usize; let tf = tc as f32; let mut resized = false; - for r in &mut o.runs { - let len = r.text.chars().count(); - let u = (start as f64 + len as f64 / 2.0) / total as f64; - start += len; + let middles = run_middles(&o.runs); + for (r, u) in o.runs.iter_mut().zip(middles) { let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue }; let (sx, sy) = (&rx.style, &ry.style); r.style.fill = lerp_paint(&sx.fill, &sy.fill, tf); @@ -752,33 +763,115 @@ pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node { // Step counts // ===================================================================================== -/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke. -fn color_distance(a: &Node, b: &Node) -> f32 { - let mut d = 0.0f32; - let mut cmp = |x: &Paint, y: &Paint| { - let cols = |p: &Paint| -> Vec { - match p { - Paint::Solid { color, .. } => vec![*color], - Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(), - _ => vec![], - } - }; - let (cx, cy) = (cols(x), cols(y)); - for (i, c) in cx.iter().enumerate() { - if let Some(o) = cy.get(i).or(cy.first()) { - let (p, q) = (c.to_rgb(), o.to_rgb()); - for k in 0..3 { - d = d.max((p[k] - q[k]).abs()); - } - } - } +/// Largest channel difference between two solid colors, as a fraction of the channel's range: two +/// tints of the same swatch by their tints, RGB by its 0 to 255 values over 256, CMYK and gray by +/// their percentages, and colors in different spaces (or Lab) by the difference of their RGB +/// values over 255. A solid paint against no paint, or anything that isn't solid, differs by 0: +/// [`lerp_paint`] switches such paints halfway rather than mixing them. +fn paint_difference(x: &Paint, y: &Paint) -> f64 { + let (Paint::Solid { color: p, swatch: sp, tint: tp }, Paint::Solid { color: q, swatch: sq, tint: tq }) = (x, y) else { + return 0.0; }; - cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint()); - cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint()); - d + if sp.is_some() && sp == sq && tp != tq { + return f64::from((tp - tq).abs()); + } + let most = |a: &[f32], b: &[f32]| a.iter().zip(b).fold(0.0f64, |m, (u, v)| m.max(f64::from((u - v).abs()))); + match (p, q) { + (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => most(&[*r, *g, *b], &[*r2, *g2, *b2]) * 255.0 / 256.0, + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => most(&[*c, *m, *y, *k], &[*c2, *m2, *y2, *k2]), + (Color::Gray { k }, Color::Gray { k: k2 }) => most(&[*k], &[*k2]), + _ => most(&p.to_rgb(), &q.to_rgb()), + } } -/// Number of intermediate steps between two keys `len` apart (along the spine). +/// Largest difference (see [`paint_difference`]) between pairs of fills and strokes, or `None` +/// when any of them is a gradient or a pattern. +fn paints_difference(pairs: &[(&Paint, &Paint)]) -> Option { + let mut d = 0.0f64; + for (x, y) in pairs { + if [x, y].iter().any(|p| matches!(p, Paint::Gradient(_) | Paint::Pattern { .. })) { + return None; + } + d = d.max(paint_difference(x, y)); + } + Some(d) +} + +/// Largest channel difference (0..1, see [`paint_difference`]) between the solid colors of two +/// keys: their top fill and stroke, the runs of two text objects paired as [`lerp_text`] pairs +/// them, and the members of two groups paired as [`Lerp::new`] pairs them. `None` when any of +/// these holds a gradient or a pattern, whose steps Smooth Color counts by distance. +fn color_distance(a: &Node, b: &Node) -> Option { + let mut d = + paints_difference(&[(&a.appearance.fill_paint(), &b.appearance.fill_paint()), (&a.appearance.stroke_paint(), &b.appearance.stroke_paint())])?; + match (&a.kind, &b.kind) { + (NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) if k1 == k2 && !(*k1 && ca.len() != cb.len()) => { + for (x, y) in ca.iter().zip(cb) { + d = d.max(color_distance(x, y)?); + } + } + (NodeKind::Text(x), NodeKind::Text(y)) => { + // The runs `lerp_text` pairs: those of `x` and of `y` at the middle of each run of either. + for u in run_middles(&x.runs).into_iter().chain(run_middles(&y.runs)) { + let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue }; + d = d.max(paints_difference(&[(&rx.style.fill, &ry.style.fill), (&rx.style.stroke, &ry.style.stroke)])?); + } + } + _ => {} + } + d.is_finite().then_some(d) +} + +/// Smooth Color's steps between two keys by distance: the largest distance between their +/// corresponding visible bounding-box edges, in mean key widths (left and right edges) or heights +/// (top and bottom), rounded, less one, from 1 to 1000. Keys with no width are measured in their +/// mean height across as well, keys with no height in their mean width, and keys with neither in +/// points. +fn spatial_steps(ba: Rect, bb: Rect) -> usize { + let (w, h) = ((ba.width() + bb.width()) / 2.0, (ba.height() + bb.height()) / 2.0); + let size = |s: f64, other: f64| { + if s > 1e-9 { + s + } else if other > 1e-9 { + other + } else { + 1.0 + } + }; + let (w, h) = (size(w, h), size(h, w)); + let q = ((ba.x0 - bb.x0).abs().max((ba.x1 - bb.x1).abs()) / w).max((ba.y0 - bb.y0).abs().max((ba.y1 - bb.y1).abs()) / h); + if !q.is_finite() { + return 1; + } + ((q + 0.5).floor() - 1.0).clamp(1.0, 1000.0) as usize +} + +/// `255 × d` steps for a color difference `d` (see [`color_distance`]), a half step rounding down, +/// from 1 to 254. +fn level_steps(d: f64) -> f64 { + // The tolerance keeps a difference that lands on a half step (128 of 256 RGB values gives + // 127.5), stored as an `f32`, from rounding up. + (255.0 * d - 0.5 - 1e-4).ceil().clamp(1.0, 254.0) +} + +/// Smooth Color's steps between two keys whose colors differ by `d` (see [`color_distance`]): +/// [`level_steps`], and at most eight per point of the largest of the keys' widths and heights +/// and the distance between their centers along either axis. +fn color_steps(d: f64, ba: Rect, bb: Rect) -> usize { + let by_color = level_steps(d); + let (ca, cb) = (ba.center(), bb.center()); + let reach = [(ca.x - cb.x).abs(), (ca.y - cb.y).abs(), ba.width(), ba.height(), bb.width(), bb.height()].into_iter().fold(0.0f64, f64::max); + let by_density = (8.0 * reach + 0.5).floor().clamp(1.0, 254.0); + if !by_density.is_finite() { + return by_color as usize; + } + by_color.min(by_density) as usize +} + +/// Number of intermediate steps between two keys. Specified Distance counts along the spine, `len` +/// long between them. Smooth Color counts from the keys' colors and visible bounds, whatever the +/// spine: keys whose colors differ make [`color_steps`] ([`level_steps`] when either key has no +/// bounds), and others make [`spatial_steps`] (1 when either key has no bounds). pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize { match spacing { BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize, @@ -787,17 +880,14 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> ((len / d).round() as i64 - 1).clamp(0, 1000) as usize } BlendSpacing::SmoothColor => { - let cd = color_distance(a, b); - if cd > 1.0 / 255.0 { - ((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256) - } else { - // Same colours: base the count on the distance between the objects. - let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds()); - let dist = match (ba, bb) { - (Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()), - _ => len, - }; - ((dist / 2.0).ceil() as usize).clamp(1, 256) + let bounds = a.visual_bounds().zip(b.visual_bounds()); + match (color_distance(a, b), bounds) { + // Colors that differ by less than a thousandth of a step count as the same. + (Some(d), Some((ba, bb))) if d * 255.0 > 1e-3 => color_steps(d, ba, bb), + (Some(d), None) if d * 255.0 > 1e-3 => level_steps(d) as usize, + // Same colors, gradients and patterns: the distance between the objects. + (_, Some((ba, bb))) => spatial_steps(ba, bb), + (_, None) => 1, } } } @@ -1148,13 +1238,19 @@ fn requested_steps(keys: &[Arc], spec: &BlendSpec, rail: Option<&Rail>) -> .collect() } -/// Most steps `spacing` makes between two keys, whatever their size and distance -/// ([`blend_step_count`]'s clamps). -fn most_steps(spacing: BlendSpacing) -> usize { +/// Most steps `spacing` makes between keys `a` and `b`, whatever their size and distance +/// ([`blend_step_count`]'s clamps). A copy of a blend is moved and scaled, never recolored +/// ([`Shape::Other`]), so Smooth Color between colors that differ makes no more than their color +/// change asks for, whatever the copy's scale or rotation. By distance, a copy turned by Align to +/// Path can measure its edges differently, so it counts as up to 1000. +fn most_steps(a: &Node, b: &Node, spacing: BlendSpacing) -> usize { match spacing { BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize, BlendSpacing::Distance(_) => 1000, - BlendSpacing::SmoothColor => 256, + BlendSpacing::SmoothColor => match color_distance(a, b) { + Some(d) if d * 255.0 > 1e-3 => level_steps(d) as usize, + _ => 1000, + }, } } @@ -1182,8 +1278,8 @@ struct StepCount { impl StepCount { /// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`]. /// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced - /// by distance (or by distance where the colours match) makes more steps scaled up, so a - /// copy counts the most its spacing can make between each pair, an upper bound for any scale. + /// by distance, or by Smooth Color, can make more steps scaled up, so a copy counts the most + /// its spacing can make between each pair ([`most_steps`]), an upper bound for any scale. fn node(&mut self, n: &Node, copy: bool) -> usize { let at = (std::ptr::from_ref(n) as usize, copy); if let Some(&known) = self.known.get(&at) { @@ -1249,7 +1345,11 @@ impl StepCount { fn most(&mut self, keys: &[Arc], spec: &BlendSpec) -> usize { let inside = self.nodes(keys, true); let each = self.steps(keys); - let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add); + let most = keys.windows(2).map(|w| match w { + [a, b] => most_steps(a, b, spec.spacing), + _ => 0, + }); + let demand = each.iter().zip(most).map(|(&c, m)| c.saturating_mul(m)).fold(0, usize::saturating_add); inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside))) } } @@ -1634,4 +1734,180 @@ mod tests { assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]); assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]); } + + /// A key for the Smooth Color tests: a rectangle of `size` (width, height) centered on `c`, + /// with `fill`, and `stroke` at `width`. + fn smooth_key(c: (f64, f64), size: (f64, f64), fill: Paint, stroke: Paint, width: f64) -> Node { + let ((cx, cy), (w, h)) = (c, size); + let rect = Rect::new(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0); + Node::path(NodeId(1), shapes::rectangle(rect), Appearance::basic(fill, stroke, width)) + } + + /// Smooth Color's steps between two filled `size` rectangles centered on `a` and `b`. + fn smooth(a: (f64, f64), b: (f64, f64), size: (f64, f64), fill_a: Color, fill_b: Color) -> usize { + let key = |c, fill| smooth_key(c, size, Paint::solid(fill), Paint::None, 0.0); + blend_step_count(&key(a, fill_a), &key(b, fill_b), BlendSpacing::SmoothColor, a.0.max(b.0) - a.0.min(b.0)) + } + + /// Smooth Color counts 255 steps for a full change of the channel that changes most, a half + /// step rounding down: RGB values over 256, CMYK and gray in percent, a spot color by its + /// tint, colors in different spaces by their RGB values over 255, a fill or a stroke, + /// whichever changes more. + #[test] + fn smooth_color_counts_a_step_per_level_of_color_change() { + let (a, b, size) = ((50.0, 150.0), (250.0, 150.0), (20.0, 20.0)); + let black = Color::rgb8(0, 0, 0); + for (level, steps) in [(1, 1), (2, 2), (5, 5), (64, 64), (127, 127), (128, 127), (129, 128), (200, 199), (254, 253), (255, 254)] { + assert_eq!(smooth(a, b, size, black, Color::rgb8(level, 0, 0)), steps, "black to red {level}"); + assert_eq!(smooth(a, b, size, black, Color::rgb8(level, level, level)), steps, "black to gray {level}"); + } + assert_eq!(smooth(a, b, size, Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)), 254); + assert_eq!(smooth(a, b, size, black, Color::rgb8(64, 128, 0)), 127, "the channel that changes most"); + assert_eq!(smooth(a, b, size, Color::rgb8(17, 42, 93), Color::rgb8(91, 147, 220)), 127); + for (percent, steps) in [(0.2, 1), (1.0, 3), (5.0, 13), (10.0, 25), (25.0, 64), (50.0, 127), (100.0, 254)] { + let p = percent / 100.0; + assert_eq!(smooth(a, b, size, Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(p, 0.0, 0.0, 0.0)), steps, "cyan {percent}%"); + assert_eq!(smooth(a, b, size, Color::gray(0.0), Color::gray(p)), steps, "gray {percent}%"); + } + let spot = |tint: f32| Paint::Solid { color: Color::rgb8(255, 0, 0).tinted(tint), swatch: Some("Spot".into()), tint }; + let key = |c, fill| smooth_key(c, size, fill, Paint::None, 0.0); + assert_eq!(blend_step_count(&key(a, spot(1.0)), &key(b, spot(0.5)), BlendSpacing::SmoothColor, 200.0), 127, "a tint of 50%"); + let stroked = |c, fill: Color, stroke: Color| smooth_key(c, size, Paint::solid(fill), Paint::solid(stroke), 1.0); + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + assert_eq!(blend_step_count(&stroked(a, red, black), &stroked(b, red, blue), BlendSpacing::SmoothColor, 200.0), 254, "the stroke changes"); + let outline = |c, stroke: Color| smooth_key(c, size, Paint::None, Paint::solid(stroke), 1.0); + assert_eq!(blend_step_count(&outline(a, red), &outline(b, blue), BlendSpacing::SmoothColor, 200.0), 254, "strokes alone"); + assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::cmyk(0.0, 0.0, 0.0, 0.0)), 128, "RGB against CMYK white"); + assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::rgb8(255, 255, 255)), 127, "RGB against RGB white"); + } + + /// Smooth Color reads the colors of group members paired in order and of text runs, and + /// counts a solid paint against no paint by distance, since such a paint switches halfway + /// instead of mixing. + #[test] + fn smooth_color_reads_the_colors_inside_groups_and_text() { + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + let square = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0)); + let group = |x: f64, top: Color| Node::new(NodeId(1), NodeKind::Group { children: vec![square(x, red), square(x, top)], clip: false }); + let steps = |a: &Node, b: &Node| blend_step_count(a, b, BlendSpacing::SmoothColor, 200.0); + assert_eq!(steps(&group(50.0, red), &group(250.0, blue)), 254, "groups whose members change color"); + assert_eq!(steps(&group(50.0, red), &group(250.0, red)), 9, "groups of the same colors"); + let text = |x: f64, c: Color| { + let style = crate::CharStyle { fill: Paint::solid(c), ..Default::default() }; + Node::new(NodeId(1), NodeKind::Text(Box::new(crate::TextObject::point(Point::new(x, 150.0), "Hi", style)))) + }; + assert_eq!(steps(&text(50.0, red), &text(250.0, blue)), 254, "text whose runs change color"); + let mut two_runs = text(250.0, red); + if let NodeKind::Text(t) = &mut two_runs.kind { + let style = crate::CharStyle { fill: Paint::solid(blue), ..t.runs[0].style.clone() }; + t.runs.push(crate::text::TextRun::new("Hi", style)); + } + assert_eq!(steps(&text(50.0, red), &two_runs), 254, "a run that turns blue against one red run"); + let empty = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::None, Paint::None, 0.0); + assert_eq!(steps(&smooth_key((50.0, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0), &empty(250.0)), 9, "a fill against none"); + let nothing = |c: Color| Node::path(NodeId(1), PathData::new(vec![]), Appearance::basic(Paint::solid(c), Paint::None, 0.0)); + assert_eq!(steps(¬hing(red), ¬hing(blue)), 254, "keys without bounds whose colors differ"); + assert_eq!(steps(¬hing(red), ¬hing(red)), 1, "keys without bounds of the same color"); + } + + /// Smooth Color makes at most eight steps per point of the largest of the keys' widths and + /// heights and the distance between their centers along either axis. + #[test] + fn smooth_color_makes_at_most_eight_steps_a_point() { + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + for (dx, steps) in [(0.0, 160), (20.0, 160), (20.1, 161), (25.0, 200), (31.5, 252), (40.0, 254)] { + assert_eq!(smooth((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0), red, blue), steps, "{dx} pt apart"); + } + assert_eq!(smooth((100.0, 150.0), (100.0, 175.0), (20.0, 20.0), red, blue), 200, "25 pt apart vertically"); + assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (5.0, 5.0), red, blue), 40, "5 pt squares on each other"); + assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (25.0, 5.0), red, blue), 200, "the longer side counts"); + assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (1.0, 1.0), red, blue), 8); + } + + /// Smooth Color between keys of the same colors counts the largest distance between their + /// corresponding visible edges in mean key widths or heights, rounded, less one; so do keys + /// with gradients, whatever their colors. + #[test] + fn smooth_color_between_the_same_colors_counts_by_distance() { + let red = Color::rgb8(255, 0, 0); + let same = |a, b, size| smooth(a, b, size, red, red); + for (dx, steps) in [(25.0, 1), (50.0, 2), (100.0, 4), (200.0, 9), (400.0, 19), (800.0, 39)] { + assert_eq!(same((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0)), steps, "20 pt squares {dx} pt apart"); + } + for (side, steps) in [(5.0, 39), (10.0, 19), (40.0, 4), (80.0, 2)] { + assert_eq!(same((50.0, 150.0), (250.0, 150.0), (side, side)), steps, "{side} pt squares"); + } + assert_eq!(same((50.0, 150.0), (250.0, 150.0), (80.0, 10.0)), 2, "wide"); + assert_eq!(same((50.0, 150.0), (250.0, 150.0), (10.0, 80.0)), 19, "tall"); + assert_eq!(same((50.0, 250.0), (250.0, 50.0), (20.0, 20.0)), 9, "diagonal"); + assert_eq!(same((50.0, 250.0), (250.0, 50.0), (80.0, 10.0)), 19, "wide and diagonal"); + assert_eq!(same((50.0, 150.0), (1050.0, 150.0), (1.0, 1.0)), 999); + assert_eq!(same((50.0, 150.0), (1052.0, 150.0), (1.0, 1.0)), 1000, "at most 1000"); + assert_eq!(same((50.0, 150.0), (50.0, 150.0), (20.0, 20.0)), 1, "at least one"); + assert_eq!(same((50.0, 150.0), (250.0, 150.0), (0.0, 20.0)), 9, "vertical lines, measured in their height"); + assert_eq!(same((50.0, 150.0), (50.0, 350.0), (20.0, 0.0)), 9, "horizontal lines, measured in their width"); + assert_eq!(same((50.0, 150.0), (60.0, 150.0), (0.0, 0.0)), 9, "points, measured in points"); + let key = |c, size, stroke: f64| smooth_key(c, size, Paint::solid(red), Paint::solid(Color::BLACK), stroke); + let wide = + blend_step_count(&key((50.0, 150.0), (10.0, 20.0), 0.0), &key((250.0, 150.0), (80.0, 20.0), 0.0), BlendSpacing::SmoothColor, 200.0); + assert_eq!(wide, 4, "keys of different widths"); + let strokes = + blend_step_count(&key((50.0, 150.0), (20.0, 20.0), 1.0), &key((250.0, 150.0), (20.0, 20.0), 10.0), BlendSpacing::SmoothColor, 200.0); + assert_eq!(strokes, 7, "strokes widen the visible edges"); + let gradient = |a: Color, b: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + Default::default(), + vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)], + )))) + }; + let shaded = |c, fill| smooth_key(c, (20.0, 20.0), fill, Paint::None, 0.0); + let blue = Color::rgb8(0, 0, 255); + for (from, to) in [(gradient(red, blue), gradient(red, blue)), (gradient(red, blue), gradient(blue, red))] { + assert_eq!(blend_step_count(&shaded((50.0, 150.0), from), &shaded((250.0, 150.0), to), BlendSpacing::SmoothColor, 200.0), 9); + } + } + + /// A Smooth Color blend counts each pair of keys on its own. + #[test] + fn smooth_color_counts_each_pair_of_keys() { + let key = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0)); + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + let spec = BlendSpec::default(); + assert_eq!(blend_steps(&[key(50.0, red), key(150.0, red), key(250.0, blue)], &spec), vec![4, 254]); + assert_eq!(blend_steps(&[key(50.0, red), key(150.0, blue), key(250.0, red)], &spec), vec![254, 254]); + } + + /// A Smooth Color blend between different colors inside another blend's keys counts as the + /// steps its colors ask for, so the outer blend keeps the steps it asks for within + /// [`MAX_BLEND_STEPS`]: 200 steps, each a copy making 254 more. The copies keep their keys' + /// colors, also halfway between a red-to-blue and a blue-to-red blend. + #[test] + fn a_smooth_color_blend_in_a_key_counts_its_color_steps() { + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + let key = |x: f64, c: Color| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0); + let inner = |x: f64, from: Color, to: Color| blend_of(key(x, from), key(x + 100.0, to), BlendSpacing::SmoothColor); + let outer = blend_of(inner(0.0, red, blue), inner(500.0, red, blue), BlendSpacing::Steps(200)); + let (keys, spec) = blend_parts(&outer).unwrap(); + assert_eq!(blend_steps(keys, spec), vec![200]); + let crossed = blend_of(inner(0.0, red, blue), inner(500.0, blue, red), BlendSpacing::Steps(199)); + let (keys, spec) = blend_parts(&crossed).unwrap(); + assert_eq!(blend_steps(keys, spec), vec![199]); + let drawn = blend_expand(keys, spec); + let Some(NodeKind::Blend { children, .. }) = drawn.get(100).map(|n| &n.kind) else { panic!("a copy of a blend halfway") }; + let fills: Vec<_> = children.iter().map(|k| k.appearance.fill_paint().color()).collect(); + assert!(fills == [Some(red), Some(blue)] || fills == [Some(blue), Some(red)], "{fills:?}"); + } + + /// A Smooth Color blend between the same colors inside another blend's keys counts as up to + /// 1000 steps, which a copy turned to a path can ask for: 200 steps of copies of it come + /// down to 99. + #[test] + fn a_smooth_color_blend_of_the_same_colors_in_a_key_counts_up_to_1000_steps() { + let red = Color::rgb8(255, 0, 0); + let key = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0); + let inner = |x: f64| blend_of(key(x), key(x + 100.0), BlendSpacing::SmoothColor); + let outer = blend_of(inner(0.0), inner(500.0), BlendSpacing::Steps(200)); + let (keys, spec) = blend_parts(&outer).unwrap(); + assert_eq!(blend_steps(keys, spec), vec![99]); + } } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 102caf53d..78f90dac6 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -352,6 +352,7 @@ impl Document { } false } + crate::id_index::note_tree_walk(); let mut p = vec![]; find(&self.layers, id, &mut p).then_some(p) } @@ -393,6 +394,7 @@ impl Document { }); } if !want.is_empty() { + crate::id_index::note_tree_walk(); let mut rest = HashMap::with_capacity(want.len()); walk(&self.layers, &want, &mut Vec::new(), &mut rest); out.extend(rest); diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index c24c18bc4..f4a037f36 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -348,11 +348,14 @@ impl Document { t.cached_bounds = None; t.cached_baselines.clear(); } + let on_path = n.extras().objects_on_path.is_some(); let mut renamed = std::collections::HashMap::new(); if let Some(ch) = n.children_mut() { let old: Vec> = std::mem::take(ch); *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); - renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id))); + if on_path { + renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id))); + } } // An Objects on Path group names its path and its objects by id: the copy names the copies. if let Some(e) = n.extras_if_any() diff --git a/crates/doc/src/id_index.rs b/crates/doc/src/id_index.rs index 65a6962f1..ed89f6ead 100644 --- a/crates/doc/src/id_index.rs +++ b/crates/doc/src/id_index.rs @@ -90,6 +90,24 @@ impl Paths { /// object and look the next one up, again and again). const MISSES_PER_REBUILD: u32 = 4; +thread_local! { + /// [`tree_walks`] of this thread. + static TREE_WALKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times lookups by id on this thread have walked a document's tree, reading up to all of +/// its nodes: one count per search of the tree and per rebuild of the cache. An edit that looks its +/// objects up one at a time counts about one walk per object; one that looks them all up at once +/// counts a few. +pub fn tree_walks() -> u64 { + TREE_WALKS.get() +} + +/// Adds one to [`tree_walks`]. +pub(crate) fn note_tree_walk() { + TREE_WALKS.set(TREE_WALKS.get().saturating_add(1)); +} + /// The cache, shared by clones of a document (each checks every path it uses against its own tree). #[derive(Default)] pub struct IdIndex(RwLock>>, std::sync::atomic::AtomicU32); @@ -125,6 +143,7 @@ impl IdIndex { } } } + note_tree_walk(); let mut out = Paths::default(); walk(layers, u32::MAX, &mut out); let out = Arc::new(out); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6d069872d..bff8e40f3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -19,6 +19,7 @@ pub mod graph; mod halftone_radii; pub mod hit; mod id_index; +pub use id_index::tree_walks; pub mod inks; mod inline; pub mod intertwine; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 781e61e68..ffa9eac0c 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -415,6 +415,68 @@ impl Document { } } + /// [`Self::update_links`] for the links `pick` holds for only, in one pass; only the nodes on the + /// way to one are copied. + pub fn update_links_where(&mut self, pick: impl Fn(&LinkInfo) -> bool, mut f: impl FnMut(&mut LinkInfo)) { + fn link_of(n: &Node) -> Option<&LinkInfo> { + match &n.kind { + NodeKind::Image(image) => image.link.as_ref(), + NodeKind::PlacedDocument(placed) => Some(&placed.link), + _ => None, + } + } + /// `n` updated in place, a copy of it when shared; left as it is when it holds no picked link. + fn update(n: &mut Arc, pick: &dyn Fn(&LinkInfo) -> bool, f: &mut dyn FnMut(&mut LinkInfo)) { + // Each child is updated through a second handle, so an updated child is a new node and a new + // pointer marks a change. + let mask = n.extras().mask.as_ref().and_then(|m| { + let mut art = Arc::clone(&m.art); + update(&mut art, pick, f); + (!Arc::ptr_eq(&art, &m.art)).then_some(art) + }); + let mut children = vec![]; + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut new = Arc::clone(c); + update(&mut new, pick, f); + if !Arc::ptr_eq(&new, c) { + children.push((i, new)); + } + } + let own = link_of(n).is_some_and(pick); + if !own && mask.is_none() && children.is_empty() { + return; + } + let n = Arc::make_mut(n); + let link = match &mut n.kind { + NodeKind::Image(image) => image.link.as_mut(), + NodeKind::PlacedDocument(placed) => Some(&mut placed.link), + _ => None, + }; + if let Some(link) = link.filter(|_| own) { + f(link); + } + if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) { + m.art = art; + } + if let Some(slots) = n.children_mut() { + for (i, c) in children { + if let Some(slot) = slots.get_mut(i) { + *slot = c; + } + } + } + } + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); + for root in roots { + update(root, &pick, &mut f); + } + } + /// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask /// art included) for which `pick` holds; only the subtrees holding one are copied. pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) { @@ -486,6 +548,40 @@ mod tests { .unwrap() } + /// `update_links_where` changes only the picked links, in a copy of the nodes on the way to them; + /// the other nodes stay shared with the document it was cloned from. + #[test] + fn update_links_where_copies_only_the_nodes_it_changes() { + let mut d = Document::new(10.0, 10.0); + image(&mut d, "a", Some(LinkInfo::new("/art/a.png"))); + image(&mut d, "b", Some(LinkInfo::new("/art/b.png"))); + image(&mut d, "c", None); + let mut copy = d.clone(); + let mut calls = 0; + copy.update_links_where( + |l| l.path == "/art/a.png", + |l| { + calls += 1; + l.relative = Some("art/a.png".into()); + }, + ); + assert_eq!(calls, 1); + let node = |d: &Document, key: &str| { + d.layers[0].children().unwrap().iter().find(|n| matches!(&n.kind, NodeKind::Image(im) if im.key == key)).unwrap().clone() + }; + let relative = |n: &Node| match &n.kind { + NodeKind::Image(im) => im.link.as_ref().and_then(|l| l.relative.clone()), + _ => None, + }; + assert_eq!((relative(&node(©, "a")), relative(&node(&d, "a"))), (Some("art/a.png".into()), None)); + assert!(Arc::ptr_eq(&node(©, "b"), &node(&d, "b")) && Arc::ptr_eq(&node(©, "c"), &node(&d, "c"))); + assert!(!Arc::ptr_eq(©.layers[0], &d.layers[0])); + // Nothing picked: nothing is copied. + let mut same = d.clone(); + same.update_links_where(|_| false, |_| {}); + assert!(Arc::ptr_eq(&same.layers[0], &d.layers[0])); + } + #[test] fn the_placeholder_is_a_png() { let png = missing_placeholder().unwrap(); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 40a1c78b0..508ce247f 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -53,8 +53,9 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, #[cfg(test)] use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ - EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_content_boxes, - envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, smooth_handles, + EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds, + envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, + set_type_ink, smooth_handles, }; pub(crate) use expand::max_outset; pub use expand::{ diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 78e1a5bf0..a58ae9c27 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -1,6 +1,7 @@ use super::expand::{Hooks, Outliner, Warper, map_node}; -use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of}; +use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of}; use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes}; +use vectorcraft_geom::Homography; /// How an envelope distorts its content. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -127,19 +128,293 @@ impl EnvelopeOptions { } } -/// The bounds of `content` in the axes of `frame`, with its strokes and geometric. Strokes scale -/// with the frame as [`Node::transform`] scales them. +/// The bounds of `content` in the axes of `frame`, with its strokes and geometric, type measured by +/// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as +/// [`Node::transform`] scales them. pub fn envelope_content_boxes(content: &[Arc], frame: Affine) -> Option<(Rect, Rect)> { let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; let to_frame = frame.inverse(); let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| { - let mut m = (**c).clone(); - m.transform(to_frame, true); - (vectorcraft_geom::union_opt(stroked, m.visual_bounds()), vectorcraft_geom::union_opt(plain, m.geometric_bounds())) + (vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame))) }); Some((stroked?, plain?)) } +/// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or +/// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead +/// of its laid-out box. Type keeps its laid-out box while nothing is registered with +/// [`set_type_ink`], and when it draws nothing. +pub fn envelope_content_bounds(content: &[Arc]) -> Option { + content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY))) +} + +/// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by +/// the given transform (outlines mapped point by point) and, for type in perspective, then drawn +/// through its projection and mapped by the second transform. `None` when it draws nothing. Type is +/// laid out above this crate, so the text crate registers one with [`set_type_ink`]. +pub type TypeInk = fn(&crate::TextObject, Affine, Option<(Homography, Affine)>) -> Option; + +/// What [`TypeInk`] maps type's text space by: a transform, and a projection with the transform +/// after it. +type TypeMap = (Affine, Option<(Homography, Affine)>); + +static TYPE_INK: std::sync::OnceLock<(TypeInk, crate::hit::LiveInputs)> = std::sync::OnceLock::new(); + +/// Register how envelopes measure type, and a number that changes whenever what it reads besides +/// the type changes, such as the installed fonts (the first registration stays). +pub fn set_type_ink(f: TypeInk, inputs: crate::hit::LiveInputs) { + // Already set: the same measure, registered by another session, an import or a renderer. + let _ = TYPE_INK.set((f, inputs)); +} + +/// How many entries each table of [`TypeInks`] holds before it is emptied. +const TYPE_INK_KEPT: usize = 1024; + +/// `map`, bit for bit, for a table key. +fn map_bits(map: &TypeMap) -> [u64; 21] { + let mut bits = [0; 21]; + let (m, projection) = map; + for (b, v) in bits.iter_mut().zip(m.as_coeffs()) { + *b = v.to_bits(); + } + if let Some((h, after)) = projection { + for (b, v) in bits[6..].iter_mut().zip(h.to_array().into_iter().chain(after.as_coeffs())) { + *b = v.to_bits(); + } + } + bits +} + +/// A key of [`TypeInks::nodes`]: the object's address, the map its outlines are measured in (an +/// affine map without its move) and the inputs number registered with [`set_type_ink`]. +type NodeKey = (usize, [u64; 21], u64); + +/// A key of [`TypeInks::content`]: two 64-bit hashes of the type without its transform's move and +/// its layout caches ([`content_hash`]), the map its outlines are measured in, and the inputs number. +type ContentKey = ([u64; 2], [u64; 21], u64); + +/// Type measured by [`type_ink`] on one thread, by the map it is measured in and the inputs +/// number: by the object (an unchanged object is found without being read), +/// and by its content (a moved or copied object is found without laying it out again). Neither +/// table keeps a copy of the type. +#[derive(Default)] +struct TypeInks { + /// By the object's address, with a weak pointer that keeps the address from being reused. + nodes: std::collections::HashMap, Option)>, + /// By the type's content. + content: std::collections::HashMap>, +} + +/// Where [`TypeInks::find`] did not find type: its keys in both tables (no content key for type +/// that could not be hashed whole). +struct Missing { + by_node: NodeKey, + content: Option, +} + +impl TypeInks { + /// The kept box of type node `n` (holding `t`) mapped by `map`, or the keys to keep it under + /// once it is measured. + fn find(&mut self, n: &Arc, t: &crate::TextObject, map: &TypeMap, inputs: u64) -> Result, Box> { + // While the table holds a weak pointer to `n`'s allocation, no other object can be at that + // address, and `n` can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut` + // moves it). + let by_node = (Arc::as_ptr(n) as usize, map_bits(map), inputs); + if let Some((_, ink)) = self.nodes.get(&by_node) { + return Ok(*ink); + } + // Shrink Text to Fit lays type out by its size on the page, so only a move keeps its layout. + let [a, b, c, d, _, _] = t.xf.as_coeffs(); + let mut text = t.clone(); + text.xf = Affine::new([a, b, c, d, 0.0, 0.0]); + text.cached_bounds = None; + text.cached_baselines.clear(); + let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs)); + match content.and_then(|key| self.content.get(&key)) { + Some(ink) => { + let ink = *ink; + self.keep_node(n, by_node, ink); + Ok(ink) + } + None => Err(Box::new(Missing { by_node, content })), + } + } + + /// Keep the box `ink` measured for type node `n` under the keys `missing`. + fn keep(&mut self, n: &Arc, missing: Missing, ink: Option) { + if let Some(key) = missing.content { + if self.content.len() >= TYPE_INK_KEPT { + self.content.clear(); + } + self.content.insert(key, ink); + } + self.keep_node(n, missing.by_node, ink); + } + + fn keep_node(&mut self, n: &Arc, by_node: NodeKey, ink: Option) { + if self.nodes.len() >= TYPE_INK_KEPT { + self.nodes.retain(|_, (weak, _)| weak.strong_count() > 0); + if self.nodes.len() >= TYPE_INK_KEPT { + self.nodes.clear(); + } + } + self.nodes.insert(by_node, (Arc::downgrade(n), ink)); + } +} + +/// Two independent 64-bit hashes of `text` as saved (the layout caches are not saved), with what +/// layout reads that is not saved: whether its first paragraph continues a thread and the bounds +/// of its inline graphics. `None` when it can't be written whole. +fn content_hash(text: &crate::TextObject) -> Option<[u64; 2]> { + use std::hash::Hasher; + /// Feeds what is written to two hashers. + struct Feed([std::collections::hash_map::DefaultHasher; 2]); + impl std::io::Write for Feed { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + for h in &mut self.0 { + h.write(buf); + } + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut feed = Feed(Default::default()); + // The second hasher starts from another state. + feed.0[1].write_u8(1); + serde_json::to_writer(&mut feed, text).ok()?; + let inline_bounds = text.runs.iter().filter_map(|r| r.inline.as_ref()).map(|i| i.bounds.map(|b| [b.x0, b.y0, b.x1, b.y1].map(f64::to_bits))); + for h in &mut feed.0 { + h.write_u8(u8::from(text.first_para_continues)); + for b in inline_bounds.clone() { + match b { + None => h.write_u8(0), + Some(bits) => { + h.write_u8(1); + bits.into_iter().for_each(|v| h.write_u64(v)); + } + } + } + } + let [a, b] = feed.0; + Some([a.finish(), b.finish()]) +} + +std::thread_local! { + /// The type [`type_ink`] measured on this thread. + static TYPE_INKS: std::cell::RefCell = std::cell::RefCell::new(TypeInks::default()); +} + +/// The box of the glyph outlines of type node `n` (holding `t`) with its text space mapped by `m` +/// ([`set_type_ink`]) and, for type in perspective, drawn through `projection` and mapped by its +/// second transform; measured again when `t`, the maps (without a move of `m` when there is no +/// projection) or the registered inputs number change. `None` while nothing is registered. +fn type_ink(n: &Arc, t: &crate::TextObject, m: Affine, projection: Option<(Homography, Affine)>) -> Option> { + let (f, inputs) = TYPE_INK.get()?; + let inputs = inputs(); + // Without a projection the type is measured without the move, which is added to the box. + let [a, b, c, d, e, g] = m.as_coeffs(); + let (map, offset) = match projection { + None => ((Affine::new([a, b, c, d, 0.0, 0.0]), None), Vec2::new(e, g)), + Some(p) => ((m, Some(p)), Vec2::ZERO), + }; + let ink = match TYPE_INKS.with_borrow_mut(|kept| kept.find(n, t, &map, inputs)) { + Ok(ink) => ink, + Err(missing) => { + // Measured outside the borrow: laying type out may measure other type. + let ink = f(t, map.0, map.1); + TYPE_INKS.with_borrow_mut(|kept| kept.keep(n, *missing, ink)); + ink + } + }; + Some(ink.map(|r| r + offset)) +} + +/// Add to `typed` the address of `n` and of each group and layer in it that holds type, which +/// [`content_box`] measures by its glyphs; is there type in `n`? One walk of the tree. +fn find_type(n: &Node, typed: &mut std::collections::HashSet) -> bool { + let holds = match &n.kind { + NodeKind::Text(_) => true, + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().fold(false, |holds, c| find_type(c, typed) | holds), + _ => false, + }; + if holds { + typed.insert(n as *const Node as usize); + } + holds +} + +/// The box of `n` an envelope maps from, in the axes `to` takes the page to: its geometric bounds, +/// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them), +/// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups +/// and layers too. Type is laid out as it is on the page, and its outlines are then taken into +/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box. +fn content_box(n: &Arc, stroked: bool, to: Affine) -> Option { + let mut typed = std::collections::HashSet::new(); + find_type(n, &mut typed); + content_box_in(n, stroked, to, &typed) +} + +/// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]). +fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet) -> Option { + // `n` in the axes of `to`. + let moved = || { + if to == Affine::IDENTITY { + return std::borrow::Cow::Borrowed(&**n); + } + let mut m = (**n).clone(); + m.transform(to, true); + std::borrow::Cow::Owned(m) + }; + let own = |m: &Node| if stroked { m.visual_bounds() } else { m.geometric_bounds() }; + if !typed.contains(&(Arc::as_ptr(n) as usize)) { + return own(&moved()); + } + match &n.kind { + NodeKind::Text(t) => { + // Type in perspective: its outlines as its projection draws them. + let ink = match n.projection() { + None => type_ink(n, t, to * t.xf, None), + Some(h) => type_ink(n, t, t.xf, Some((h, to))), + } + .flatten(); + let m = moved(); + let Some(ink) = ink else { return own(&m) }; + if !stroked { + return Some(ink); + } + let (Some(v), Some(g)) = (m.visual_bounds(), m.geometric_bounds()) else { return Some(ink) }; + Some(Rect::new( + ink.x0 - (g.x0 - v.x0).max(0.0), + ink.y0 - (g.y0 - v.y0).max(0.0), + ink.x1 + (v.x1 - g.x1).max(0.0), + ink.y1 + (v.y1 - g.y1).max(0.0), + )) + } + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + // A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`. + let clip = children.first()?; + content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed) + } + NodeKind::Layer { .. } | NodeKind::Group { .. } => { + let layer = matches!(n.kind, NodeKind::Layer { .. }); + let members = n.editing_children().iter(); + if stroked { + // The container's own strokes, scaled by `to` as `Node::transform` scales them, + // without copying its members. + let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false }); + own_strokes.appearance = n.appearance.clone(); + own_strokes.transform(to, true); + own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed))) + } else { + members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed))) + } + } + _ => own(&moved()), + } +} + /// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for /// content with no width or no height, or for margins an `f32` can't hold. pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> { @@ -265,20 +540,13 @@ pub struct EnvelopeMap<'a> { } impl<'a> EnvelopeMap<'a> { - /// The map of an envelope of `kind` around `content` with `frame` and `options`. `None` when - /// the content has no bounds. + /// The map of an envelope of `kind` around `content` with `frame` and `options`, from the + /// content's bounds with type measured by its glyph outlines ([`envelope_content_bounds`]). + /// `None` when the content has no bounds. pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option { let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; let to_frame = frame.inverse(); - let src = if frame == Affine::IDENTITY { - nodes_bounds(content)? - } else { - content.iter().try_fold(None, |acc, c| { - let mut m = (**c).clone(); - m.transform(to_frame, false); - Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds())) - })?? - }; + let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?; let src = options.source_box(src); Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) } @@ -378,3 +646,112 @@ pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CharStyle, TextObject}; + + /// Look `n` up in `kept`, measuring it (and counting the measures) when it isn't there. + fn look_up(kept: &mut TypeInks, n: &Arc, linear: Affine, measured: &mut usize) -> Option { + let NodeKind::Text(t) = &n.kind else { panic!("type") }; + match kept.find(n, t, &(linear, None), 0) { + Ok(ink) => ink, + Err(missing) => { + *measured += 1; + let ink = Some(Rect::new(0.0, 0.0, *measured as f64, 1.0)); + kept.keep(n, *missing, ink); + ink + } + } + } + + fn type_node(i: usize, text: &str) -> Arc { + let t = TextObject::point(Point::new(i as f64 * 10.0, 0.0), text, CharStyle::default()); + Arc::new(Node::new(NodeId(i as u64), NodeKind::Text(Box::new(t)))) + } + + /// Many type objects are each measured once however often they are looked up; a moved or copied + /// object is found by its content, and a changed one or another transform is measured again. + #[test] + fn measured_type_is_kept_for_many_objects() { + let mut kept = TypeInks::default(); + let mut measured = 0; + let nodes: Vec<_> = (0..200).map(|i| type_node(i, &format!("Type {i}"))).collect(); + for _ in 0..5 { + for n in &nodes { + look_up(&mut kept, n, Affine::IDENTITY, &mut measured); + } + } + assert_eq!(measured, 200); + let mut moved = (*nodes[7]).clone(); + moved.transform(Affine::translate((300.0, 40.0)), true); + let moved = Arc::new(moved); + assert_eq!(look_up(&mut kept, &moved, Affine::IDENTITY, &mut measured), look_up(&mut kept, &nodes[7], Affine::IDENTITY, &mut measured)); + assert_eq!(measured, 200, "a moved object is found by its content"); + let changed = type_node(7, "Changed"); + look_up(&mut kept, &changed, Affine::IDENTITY, &mut measured); + assert_eq!(measured, 201, "a changed object is measured again"); + look_up(&mut kept, &nodes[7], Affine::rotate(0.5), &mut measured); + assert_eq!(measured, 202, "another transform is measured again"); + } + + /// Type that differs only in what layout reads but files don't save (a first paragraph that + /// continues a thread, the resolved bounds of an inline graphic) is measured again. + #[test] + fn type_that_differs_in_what_is_not_saved_is_measured_again() { + let mut kept = TypeInks::default(); + let mut measured = 0; + let mut t = TextObject::point(Point::ZERO, "Type", CharStyle::default()); + t.runs.push(crate::TextRun::new("", CharStyle::default())); + t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None }); + let node = |t: &TextObject| Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())))); + look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured); + look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured); + assert_eq!(measured, 1, "a copy is found by its content"); + t.first_para_continues = true; + look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured); + assert_eq!(measured, 2, "a first paragraph that continues a thread"); + if let Some(inline) = &mut t.runs[1].inline { + inline.bounds = Some(Rect::new(0.0, 0.0, 10.0, 20.0)); + } + look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured); + assert_eq!(measured, 3, "an inline graphic's bounds"); + } + + /// A stroked group of type in a scaled frame reaches past its members by its own strokes scaled + /// with the frame, as the group moved into the frame does; deeply nested groups are measured in + /// one walk each. + #[test] + fn a_stroked_group_of_type_is_measured_with_its_strokes_in_the_frame() { + let text = type_node(1, "Group"); + let mut group = Node::group(NodeId(2), vec![text.clone()]); + group.appearance = crate::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::solid(Color::BLACK), 4.0).into(); + let group = Arc::new(group); + let to = Affine::scale(2.0); + let mut moved = (*group).clone(); + moved.transform(to, true); + let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to))); + assert!(want.is_some()); + assert_eq!(content_box(&group, true, to), want); + let mut nested = group; + for i in 0..300 { + nested = Arc::new(Node::group(NodeId(10 + i), vec![nested])); + } + assert!(content_box(&nested, false, Affine::IDENTITY).is_some()); + } + + /// A table that fills up is emptied instead of growing, and what is looked up after that is + /// measured again. + #[test] + fn the_tables_stay_bounded() { + let mut kept = TypeInks::default(); + let mut measured = 0; + let nodes: Vec<_> = (0..TYPE_INK_KEPT + 10).map(|i| type_node(i, &format!("Type {i}"))).collect(); + for n in &nodes { + look_up(&mut kept, n, Affine::IDENTITY, &mut measured); + } + assert!(kept.content.len() <= TYPE_INK_KEPT && kept.nodes.len() <= TYPE_INK_KEPT, "{} {}", kept.content.len(), kept.nodes.len()); + assert_eq!(measured, TYPE_INK_KEPT + 10); + } +} diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 528e33e3e..5caada836 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -7,6 +7,7 @@ //! those links (and colours) wherever art and swatches hold them, copying only the nodes that //! change. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use vectorcraft_color::swatch::REGISTRATION; @@ -85,6 +86,12 @@ impl Document { fn numbered_swatch_name(&self, base: &str, from: usize) -> String { (from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string()) } + /// Names for several new swatches: [`FreeSwatchNames::take`] gives what + /// [`Document::free_swatch_name`] gives once the swatches named before it are added. + pub fn free_swatch_names(&self) -> FreeSwatchNames { + let taken = self.swatches_iter().map(|s| s.name.clone()).chain(self.swatch_groups.iter().map(|g| g.name.clone())); + FreeSwatchNames { taken: taken.chain([REGISTRATION.to_string()]).collect(), next: HashMap::new() } + } /// The name a new swatch of `paint` gets by default, unused in the document: a colour's values /// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1", /// "New Pattern Swatch 1"… @@ -200,6 +207,52 @@ pub fn color_name(c: Color) -> String { } } +/// The swatch and color group names of a document ([`Document::free_swatch_names`]) and the names +/// handed out since, for naming many new swatches without searching the document for each name. +#[derive(Clone, Debug)] +pub struct FreeSwatchNames { + taken: HashSet, + /// For each base and first number already numbered, the number its next numbered name is + /// looked for from: names are only ever added, so the numbers below it stay taken. + next: HashMap<(String, usize), usize>, +} + +impl FreeSwatchNames { + /// `base`, or the first of "base 2", "base 3"… not taken, as [`Document::free_swatch_name`] + /// gives it; the name is taken from then on. + pub fn take(&mut self, base: &str) -> String { + let name = if self.taken.contains(base) { self.numbered(base, 2) } else { base.to_string() }; + self.taken.insert(name.clone()); + name + } + /// The name [`Document::new_swatch_name`] gives a new swatch of `paint`; the name is taken + /// from then on. + pub fn take_new(&mut self, paint: &Paint) -> String { + let base = match paint { + Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.take(&format!("{n} {}%", tint_percent(*tint))), + Paint::Solid { color, .. } => return self.take(&color_name(*color)), + Paint::Gradient(_) => "New Gradient Swatch", + _ => "New Pattern Swatch", + }; + let name = self.numbered(base, 1); + self.taken.insert(name.clone()); + name + } + /// Count `name` as taken (a swatch or color group added another way). + pub fn insert(&mut self, name: &str) { + self.taken.insert(name.to_string()); + } + /// The first of "base `from`", "base `from + 1`"… not taken. + fn numbered(&mut self, base: &str, from: usize) -> String { + let key = (base.to_string(), from); + let start = self.next.get(&key).copied().unwrap_or(from); + let (i, name) = + (start..).map(|i| (i, format!("{base} {i}"))).find(|(_, n)| !self.taken.contains(n)).unwrap_or_else(|| (start, base.to_string())); + self.next.insert(key, i + 1); + name + } +} + /// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`, /// true when it changed them. pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool + 'a; @@ -419,6 +472,56 @@ mod tests { assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0"); } + #[test] + fn free_swatch_names_match_naming_each_swatch_as_it_is_added() { + let (mut d, _, _) = doc(); + let mut names = d.free_swatch_names(); + for base in ["Brand", "Brand", "Mine", "Fresh", "Fresh", "Brand 2", "Brand", REGISTRATION, "Fresh 3", "Fresh", "Mine 2"] { + let want = d.free_swatch_name(base); + assert_eq!(names.take(base), want, "{base}"); + d.swatches.push(Swatch { name: want, paint: Paint::solid(Color::BLACK), global: false, spot: false }); + } + let added: Vec<&str> = d.swatches.iter().rev().take(11).rev().map(|w| w.name.as_str()).collect(); + assert_eq!( + added, + ["Brand 2", "Brand 3", "Mine 2", "Fresh", "Fresh 2", "Brand 2 2", "Brand 4", "[Registration] 2", "Fresh 3", "Fresh 4", "Mine 2 2"] + ); + } + + #[test] + fn free_swatch_names_match_new_swatch_names_as_swatches_are_added() { + let (mut d, _, _) = doc(); + let red = Color::rgb(1.0, 0.0, 0.0); + d.swatches.push(Swatch { name: "New Gradient Swatch 2".into(), paint: Paint::solid(red), global: false, spot: false }); + let mut names = d.free_swatch_names(); + // A name taken another way (a group made alongside) counts for both. + names.insert("R=255 G=0 B=0 3"); + d.swatch_groups.push(SwatchGroup { name: "R=255 G=0 B=0 3".into(), swatches: vec![] }); + let tint = Paint::Solid { color: red, swatch: Some("Brand".into()), tint: 0.4 }; + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); + let paints = [Paint::solid(red), Paint::solid(red), gradient.clone(), tint.clone(), Paint::solid(red), gradient.clone(), tint, gradient]; + let mut added = vec![]; + for paint in paints { + let want = d.new_swatch_name(&paint); + assert_eq!(names.take_new(&paint), want); + d.swatches.push(Swatch { name: want.clone(), paint, global: false, spot: false }); + added.push(want); + } + assert_eq!( + added, + [ + "R=255 G=0 B=0", + "R=255 G=0 B=0 2", + "New Gradient Swatch 1", + "Brand 40%", + "R=255 G=0 B=0 4", + "New Gradient Swatch 3", + "Brand 40% 2", + "New Gradient Swatch 4" + ] + ); + } + #[test] fn mapping_copies_only_changed_nodes() { let (mut d, a, b) = doc(); diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index a257e2e0a..f189801bc 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -228,7 +228,8 @@ pub struct ParaStyle { pub leading_model: LeadingModel, /// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line /// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before - /// it and imported text keep [`Burasagari::None`]. + /// it and imported text keep [`Burasagari::None`], except legacy `.ai` type with binding + /// punctuation on, which opens with [`Burasagari::Forced`]. pub burasagari: Burasagari, /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a /// line. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8835d769f..b251b15a6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -84,8 +84,8 @@ pub use reshape::{ }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ - EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, - validate_three_d, view_options, + EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, + three_d_params_as, validate_solid, validate_three_d, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use trap::TRAP; diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index ee88f485c..c4b6be4b7 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -39,6 +39,8 @@ pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option { /// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a /// document, symbol instances and pattern tiles stay as they are). fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { + // Envelopes map type from its glyphs here too, without an engine session. + vectorcraft_text::register_envelope_ink(); let outline = |n: &Node| outline_object(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc }) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7025f8f6f..3319d79af 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -25,6 +25,32 @@ pub fn is_three_d(id: &str) -> bool { id == crate::REVOLVE || SOLIDS.contains(&id) } +/// The 3D and Materials panel's object types and the effect that draws each: (type, effect id). +/// Plane keeps the art flat, which is what Rotate draws. +pub const THREE_D_TYPES: [(&str, &str); 4] = [("plane", ROTATE), ("extrude", EXTRUDE), ("revolve", crate::REVOLVE), ("inflate", INFLATE)]; + +/// The parameters of 3D effect `to` for an effect `from` with `params`, as switching the object +/// type keeps them: every setting `to` takes keeps its value from `params` (the defaults of `from` +/// under them), each rotation angle wrapped into -180° to 180° when `to` is not Revolve, and the +/// settings only `to` takes start at their defaults. Settings `to` does not take are dropped. +pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value { + let current = crate::merged_params(from, params); + let mut out = crate::default_params(to).and_then(|v| v.as_object().cloned()).unwrap_or_default(); + for (key, value) in out.iter_mut() { + if let Some(v) = current.get(key) { + value.clone_from(v); + } + } + if to != crate::REVOLVE { + for key in ["rotationX", "rotationY", "rotationZ"] { + if let Some(a) = out.get(key).and_then(Value::as_f64).filter(|a| !(-180.0..=180.0).contains(a)) { + out.insert(key.into(), serde_json::json!((a + 180.0).rem_euclid(360.0) - 180.0)); + } + } + } + Value::Object(out) +} + fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> { fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str())) } diff --git a/crates/effects/tests/envelope_ink.rs b/crates/effects/tests/envelope_ink.rs new file mode 100644 index 000000000..a95906cc1 --- /dev/null +++ b/crates/effects/tests/envelope_ink.rs @@ -0,0 +1,29 @@ +//! Live evaluation without an engine session makes envelopes measure type by its glyph outlines. +//! One test in its own process, so nothing else has registered the measure before the evaluation. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Point, Rect}; + +/// The largest distance between the edges of `a` and `b`. +fn off(a: Rect, b: Rect) -> f64 { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())) +} + +#[test] +fn live_evaluation_measures_type_in_envelopes_by_its_glyphs() { + let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() }); + let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())))); + let laid = n.geometric_bounds().unwrap(); + let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()); + assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}"); + let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap(); + assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}"); + + let _ = vectorcraft_effects::expand_live(None, &n); + let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap(); + assert!(off(after, ink) < 1e-6, "after live evaluation, type is measured by its glyphs: {after:?} vs {ink:?}"); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6789a2116..eddf966db 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -66,3 +66,5 @@ vectorcraft-pdf = { workspace = true, features = ["read-count"] } wat = { workspace = true } # Zstandard streams for the Illustrator editing data fuzz tests. ruzstd = { workspace = true } +# The JavaScript parser's lexer, compared with the script checker's on the same scripts. +vectorcraft-js = { workspace = true } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index f02d6f6aa..a26c75da2 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -575,21 +575,26 @@ pub(crate) fn named_id(p: &Value) -> Result> { } } -/// Objects a command targets: explicit `ids` param or the selection. -pub(crate) fn targets(s: &Session, p: &Value) -> Result> { - // Objects given are used as given: a value that isn't an object id fails rather than the - // command acting on the selection instead (#785). - match p.get("ids").filter(|v| !v.is_null()) { - Some(Value::Array(a)) => return a.iter().map(object_id).collect(), - Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))), - None => {} +/// The object `p` gives as `key`, for a command that acts on the selection (or makes something new) +/// without it: `None` when `p` leaves it out or gives null. Any other value that isn't an object id +/// (`"abc"`, `"5"`, `true`, `{}`, `-1`, `2.5`) is an error under `cmd` that names `key`, never the +/// selection. The id is not looked up in the document. +pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(r)) if key == "id" && r.starts_with('$') => { + Err(bad(cmd, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) + } + Some(v) => v.as_u64().map(|id| Some(NodeId(id))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))), } - match p.get("id").filter(|v| !v.is_null()) { - Some(v @ Value::Number(_)) => Ok(vec![object_id(v)?]), - // A step reference a batch couldn't resolve (other strings are other ids, such as an - // effect's). - Some(Value::String(r)) if r.starts_with('$') => Err(unresolved_reference(r)), - _ => Ok(s.doc()?.selection.objects.clone()), +} + +/// Objects a command targets: its `ids`, else its `id` as `ids` of one ([`named_ids`]), else the +/// selection. An `ids` or `id` that isn't an object id is an error, never the selection. +pub(crate) fn targets(s: &Session, p: &Value) -> Result> { + match named_ids(p)? { + Some(ids) => Ok(ids), + None => Ok(s.doc()?.selection.objects.clone()), } } @@ -722,6 +727,10 @@ pub use pathops::shape_count; /// The gaps View › Show Live Paint Gaps shows on a Live Paint group. pub use buildcmds::live_paint_gaps; +/// Whether Reset Transformation changes a symbol instance (the Symbols panel menu and the +/// Control bar enable it when it changes one). +pub use brushsym::reset_transformation_changes; + /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 9cfedcb7a..30e48d4fc 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, |s, p| add_item(s, p, false) ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, clear_appearance ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, reduce_basic ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), 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)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, set_item ), @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, 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)} remove those fills/strokes from each target object's own stack", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, 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)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, remove_item ), @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, 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)} copy fills/strokes with their effects, as Alt-dragging rows places them", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, 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)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", has_doc, duplicate_item ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", has_doc, show_all_hidden ), @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {source, target}", has_doc, transfer ), @@ -551,12 +551,17 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { // Where the new fill went in on the active item's object, when it went above the active row. let mut above = None; s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { + // The objects that got a new topmost row of the kind: the series stores its paint even when the object showed it. + let mut added = Vec::new(); for (id, row) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { if let Some(i) = n.appearance.item_of_kind(*row, true) && let Some(paint) = n.appearance.paint_at(Some(i), true).cloned() { n.appearance.insert_item(i + 1, AppearanceItem::Fill(FillLayer::new(paint))); + if let Some(kind) = top_row_kind(&n.appearance, i + 1) { + added.push((*id, kind)); + } if active == Some(*id) { above = Some(i + 1); } @@ -591,10 +596,14 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } } else { n.appearance.items.push(item); + added.push((*id, fill)); } } } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + let mut rows = top_paint_changed(&before, d, &ids); + rows.extend(painted_rows(d, added)); + super::graph::capture_series_top_paints(d, &rows); Ok(()) })?; // A fill added above the active row becomes the active row; otherwise the rows above the new @@ -614,7 +623,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; s.active_appearance_item = None; ok() @@ -626,15 +636,37 @@ fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() } -/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than -/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d` +/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { - d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0) + d.node(id) + .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity)) + .unwrap_or(1.0) }; ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() } +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another paint, or a +/// stroke another weight, in `d` than in `before` (no row counts as no paint), for +/// [`super::graph::capture_series_top_paints`]. +fn top_paint_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { + let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| d.node(id).map(|n| super::graph::shown_paint(n, fill)); + ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() +} + +/// `rows` (each object with the kind of row) whose topmost visible row of that kind has paint: a command that wrote them +/// replaces the paint another mark of the series left on the graph, even when the mark's own row comes out as it was. +fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator) -> Vec<(NodeId, bool)> { + rows.into_iter().filter(|(id, fill)| d.node(*id).is_some_and(|n| !super::graph::shown_paint(n, *fill).0.is_none())).collect() +} + +/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. +fn top_row_kind(ap: &Appearance, at: usize) -> Option { + let fill = ap.items.get(at)?.is_fill(); + (ap.items.iter().rposition(|it| it.is_fill() == fill && it.visible()) == Some(at)).then_some(fill) +} + /// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A /// group keeps no fill or stroke rows of its own. fn clear(n: &mut Node) { @@ -672,7 +704,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result { } let changed = if copy { vec![target] } else { vec![source, target] }; let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept)) + // The target last: on marks of one series, its paint is the one the series keeps. + let mut rows = top_paint_changed(&before, d, &changed); + rows.extend(painted_rows(d, both_kinds(&[target]))); + let painted = super::graph::capture_series_top_paints(d, &rows); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -726,7 +762,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; s.active_appearance_item = None; ok() @@ -752,10 +789,6 @@ fn set_item(s: &mut Session, p: &Value) -> Result { for (id, at) in ids.iter().zip(&per) { let Some(&idx) = at.first() else { continue }; let Some(n) = d.node_mut(*id) else { continue }; - let items = &n.appearance.items; - if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) { - edited.push((*id, fill)); - } if p.get("weight").is_none() && let Some(pa) = &paint { @@ -785,10 +818,26 @@ fn set_item(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("visible").and_then(Value::as_bool) { *vis = v; } + let items = &n.appearance.items; + if let Some(fill) = + items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx)) + { + edited.push((*id, fill)); + } } - // The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again. - let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + // The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when + // it is drawn again, and hiding or showing a row changes which row that is. + let mut opacities = top_opacity_changed(&before, d, &ids); + if p.get("opacity").and_then(Value::as_f64).is_some() { + opacities.extend(edited.iter().copied()); + } + let kept = super::graph::capture_series_opacity(d, &opacities); + // A weight goes with the paint of its stroke; a stroke without paint leaves the series' paint alone. + let written = edited.iter().copied().filter(|(_, fill)| paint.is_some() || (!*fill && p.get("weight").is_some())); + let mut rows = top_paint_changed(&before, d, &ids); + rows.extend(painted_rows(d, written)); + let painted = super::graph::capture_series_top_paints(d, &rows); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; if let Some(pa) = &paint { s.remember_paint(pa); @@ -832,7 +881,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count())); ok() @@ -862,6 +912,9 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let per = items_per_target(&before, &ids, &idx, aligned(p, C)?); s.edit("Duplicate Item", |d, _| { let mut any = false; + // Copies that became the topmost visible row of their kind: the series stores their paint even when the object + // showed it. + let mut added = Vec::new(); for (id, idx) in ids.iter().zip(&per) { let Some(n) = d.node_mut(*id) else { continue }; let has_contents = n.contents_label().is_some(); @@ -877,9 +930,11 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { if active == Some(*id) { active_at = Some(at); } + let count = copies.len(); for (k, item) in copies.into_iter().enumerate() { ap.insert_item(at + k, item); } + added.extend((at..at + count).filter_map(|k| top_row_kind(ap, k)).map(|kind| (*id, kind))); if let Some(c) = contents.filter(|_| has_contents) { ap.set_contents_at(c); } @@ -893,6 +948,10 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { if let Some(item) = ap.items.get(i).cloned() { first_at.get_or_insert(i + 1); ap.insert_item(i + 1, item); + // Copies below this one go in lower down, so a copy that is the topmost row of its kind stays it. + if let Some(kind) = top_row_kind(ap, i + 1) { + added.push((*id, kind)); + } any = true; } } @@ -901,6 +960,9 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no item at index {}", idx[0]))); } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + let mut rows = top_paint_changed(&before, d, &ids); + rows.extend(painted_rows(d, added)); + super::graph::capture_series_top_paints(d, &rows); Ok(()) })?; // Rows at or above each copy move up by one (by the block, for copies placed at `to`). @@ -918,11 +980,14 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { /// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects. fn show_all_hidden(s: &mut Session, p: &Value) -> Result { let roots = require_targets(s, p, "appearance.showAllHidden")?; + let before = s.doc()?.doc.clone(); let ids = s.edit("Show All Hidden Attributes", |d, _| { let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); if shown.is_empty() { return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into())); } + super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown)); + super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown)); Ok(shown) })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) @@ -962,7 +1027,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result { } } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept)) + let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) })?; // The moved row stays the active one; the rows it passed shift by one. s.remap_appearance_item(&ids, |a| { @@ -1006,7 +1072,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result Result { picked.apply(n, &attrs, append); } } - let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets)); - Ok(count_changed(d, &before, &targets) + usize::from(kept)) + // The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds + // only the colors of the visible rows the source has. + let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke); + let source_has = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind); + let source_shows = |kind: bool| picked.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible()); + let (paints, opacities) = if append { + let rows = (fill.color && source_shows(true), stroke.color && source_shows(false)); + (rows, rows) + } else { + ( + (fill.color, stroke.color || (stroke.weight && source_has(false))), + (fill.transparency && source_has(true), stroke.transparency && source_has(false)), + ) + }; + // A pick of some attributes edits the topmost row of its kind, which may be hidden; `append` and a whole pick write rows. + let rows_written = append || (fill == FillAttrs::ALL && stroke == StrokeAttrs::ALL); + let top_shows = |id: NodeId, kind: bool| { + let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); + items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(|it| it.visible()) + }; + let written = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + targets + .iter() + .flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]) + .flatten() + .filter(|(id, kind)| rows_written || top_shows(*id, *kind)) + .collect() + }; + let mut opacity_rows = top_opacity_changed(&before, d, &targets); + opacity_rows.extend(painted_rows(d, written(opacities))); + let mut rows = top_paint_changed(&before, d, &targets); + rows.extend(painted_rows(d, written(paints))); + let kept = super::graph::capture_series_opacity(d, &opacity_rows); + let painted = super::graph::capture_series_top_paints(d, &rows); + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 3e097f8fc..8cf3a2195 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -24,6 +24,9 @@ use super::{ }; use crate::EngineError; +/// Whether Reset Transformation changes a symbol instance. +pub use symbols::reset_transformation_changes; + mod brushes; mod dynamic; mod symbols; @@ -40,8 +43,8 @@ pub(crate) use symbols::{ stored_natural_size, }; use symbols::{ - symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace, - symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, + symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_instance_reset_transform, symbol_list, symbol_new, symbol_options, + symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, }; const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; @@ -140,7 +143,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), @@ -272,6 +275,15 @@ pub fn specs() -> Vec { has_selection, symbol_instance_reset ), + cmd!( + "symbol.instance.resetTransform", + "Reset Transformation", + [], + None, + "{ids?|id?} give the selected symbol instances (in groups too) their symbol's own scale, without rotation, shear or reflection, keeping the center of each one's bounding box in place, as one undo step; other objects, instances in perspective and instances already at their symbol's scale are left as they are, and no undo step is added when nothing changes; an id naming no object, or no symbol instance among the objects, is an error → {ids: the instances changed}", + has_selection, + symbol_instance_reset_transform + ), cmd!( "symbol.setCurrent", "Current Symbol", diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 406511ff1..69bfcb66e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -696,6 +696,62 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&ids) })) } +/// How far a transform coefficient may be from Reset Transformation's for the instance to count as +/// already reset. +const RESET_TOLERANCE: f64 = 1e-9; + +/// The transform Reset Transformation gives instance `n` of the document of `boxes`: its symbol's +/// own scale, without rotation, shear or reflection, with the center of its art's bounding box +/// where it is. `None` when [`reset_transformation_changes`] is false for `n`. +fn reset_transformation(boxes: &vectorcraft_doc::SymbolBoxes, n: &Node) -> Option { + let d = boxes.document(); + if !reset_transformation_changes(d, n) { + return None; + } + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; + let scale = d.symbol_natural_xf(symbol); + // The art's box in symbol space (the ±HALF square when the art cannot be measured): its + // center keeps its place on the page. + let art = boxes.art_bounds(symbol).unwrap_or_else(|| vectorcraft_geom::Rect::new(-HALF, -HALF, HALF, HALF)); + let center = xf.transform_rect_bbox(art).center(); + Some(Affine::translate(center - scale * art.center()) * scale) +} + +/// Does Reset Transformation change `n`? True for an instance of a symbol `d` holds, not in +/// perspective, whose scale, rotation, shear or reflection differs from the symbol's own scale +/// (its stored natural size, or the art unscaled when that size is not positive and finite). +/// It does not measure the art. +pub fn reset_transformation_changes(d: &Document, n: &Node) -> bool { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return false }; + if n.extras().perspective.is_some() || !d.symbols.iter().any(|s| s.name == *symbol) { + return false; + } + let natural = d.symbol_natural_xf(symbol).as_coeffs(); + xf.as_coeffs()[..4].iter().zip(&natural[..4]).any(|(a, b)| (a - b).abs() > RESET_TOLERANCE) +} + +pub(super) fn symbol_instance_reset_transform(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + let d = &s.doc()?.doc; + let boxes = d.symbol_boxes(); + let changes: Vec<(NodeId, Affine)> = ids.iter().filter_map(|id| Some((*id, reset_transformation(&boxes, d.node(*id)?)?))).collect(); + if !changes.is_empty() { + s.edit("Reset Transformation", |d, _| { + for (id, new) in &changes { + if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { + *xf = *new; + } + } + Ok(()) + })?; + } + let changed: Vec = changes.iter().map(|(id, _)| *id).collect(); + Ok(json!({ "ids": ids_json(&changed) })) +} + pub(super) fn symbol_set_current(s: &mut Session, p: &Value) -> Result { let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?; set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?; diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 22159ca2a..4488b4746 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -241,9 +241,29 @@ pub(super) struct Used { impl Used { fn of<'a>(defs: &Defs<'a>, nodes: impl IntoIterator) -> Self { let mut used = Used::default(); - let mut todo: Vec> = nodes.into_iter().map(Source::Node).collect(); + used.follow(defs, nodes.into_iter().map(Source::Node).collect()); + used + } + + /// Every character style of `defs` (with `chars`) and every paragraph style (with `paras`), and + /// the resources they use, styles of the other kind included. + fn of_text_styles(defs: &Defs<'_>, chars: bool, paras: bool) -> Self { + let mut used = Used::default(); + let mut todo = vec![]; + for s in defs.char_styles.iter().filter(|_| chars) { + used.note(defs, Ref::Name(Res::CharStyle, &s.name), &mut todo); + } + for s in defs.para_styles.iter().filter(|_| paras) { + used.note(defs, Ref::Name(Res::ParaStyle, &s.name), &mut todo); + } + used.follow(defs, todo); + used + } + + /// Note the references of `todo`'s sources and of the definitions they lead to. + fn follow<'a>(&mut self, defs: &Defs<'a>, mut todo: Vec>) { while let Some(src) = todo.pop() { - let mut found = |r: Ref<'_>| used.note(defs, r, &mut todo); + let mut found = |r: Ref<'_>| self.note(defs, r, &mut todo); match src { Source::Node(n) => node_refs(n, &mut found), Source::Appearance(a) => appearance_refs(a, &mut found), @@ -251,7 +271,6 @@ impl Used { Source::Paint(p) => paint_refs(p, &mut found), } } - used } /// Note `r`; a definition seen for the first time joins `todo`. @@ -505,7 +524,7 @@ impl SwatchChoices { } /// How pasted art is relinked to the document's resources. -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] struct Relink { renames: BTreeMap>, /// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped. @@ -665,10 +684,234 @@ impl SymbolOptions { } } +/// A character or paragraph style, symbol (with the Symbol Options copied with it), pattern or +/// brush (placed with the others only when they are placed in use order) to place +/// ([`Clipboard::import_used`]). +#[derive(Clone, Copy)] +enum Def<'c> { + Style(Res, &'c TextStyleDef), + Symbol(&'c Symbol, Option<&'c SymbolOptions>), + Pattern(&'c PatternDef), + Brush(&'c Brush), +} + +impl<'c> Def<'c> { + fn key(&self) -> (Res, &'c str) { + match self { + Def::Style(r, s) => (*r, &s.name), + Def::Symbol(s, _) => (Res::Symbol, &s.name), + Def::Pattern(p) => (Res::Pattern, &p.name), + Def::Brush(b) => (Res::Brush, &b.name), + } + } + + /// Call `f` with each resource it refers to by name. + fn each_use(&self, f: &mut dyn FnMut(Res, &str)) { + let mut g = |r: Ref<'_>| { + if let Ref::Name(res, n) = r { + f(res, n); + } + }; + match self { + Def::Style(_, s) => attrs_refs(&s.attrs, &mut g), + Def::Symbol(s, _) => node_refs(&s.art, &mut g), + Def::Pattern(p) => p.art.iter().for_each(|a| node_refs(a, &mut g)), + Def::Brush(b) => b.art().into_iter().for_each(|a| node_refs(a, &mut g)), + } + } + + /// Is it the definition `d` has under `name`, once relinked by `r`? (`brushes`: the brush + /// library of `d`.) + fn same_in(&self, d: &Document, brushes: &Brushes<'_>, name: &str, r: &Relink) -> bool { + match self { + Def::Style(res, s) => { + let theirs = if *res == Res::CharStyle { &d.char_styles } else { &d.para_styles }; + let mut attrs = s.attrs.clone(); + r.attrs(&mut attrs); + theirs.iter().any(|x| x.name == name && x.attrs == attrs) + } + Def::Symbol(s, options) => d.symbols.iter().any(|x| { + x.name == name + && options.is_none_or(|o| SymbolOptions::of(d, name) == *o) + && same_art(r, std::slice::from_ref(&x.art), std::slice::from_ref(&s.art)) + }), + Def::Pattern(p) => { + let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; + d.pattern(name).is_some_and(|x| bare(x) == bare(p) && same_art(r, &x.art, &p.art)) + } + Def::Brush(b) => brushes.get().iter().any(|x| x.name == name && same_brush(r, x, b)), + } + } + + /// The names of its kind in `d` (`brushes`: its brush library) that are its name or a numbered + /// copy of it. + fn names_in(&self, d: &Document, brushes: &Brushes<'_>) -> Vec { + let (res, name) = self.key(); + let names: Vec = match res { + Res::CharStyle => d.char_styles.iter().map(|s| s.name.clone()).collect(), + Res::ParaStyle => d.para_styles.iter().map(|s| s.name.clone()).collect(), + Res::Symbol => d.symbols.iter().map(|s| s.name.clone()).collect(), + Res::Brush => brushes.get().iter().map(|b| b.name.clone()).collect(), + _ => d.patterns.iter().map(|p| p.name.clone()).collect(), + }; + names.into_iter().filter(|n| copy_of(name, n)).collect() + } +} + +/// `defs` in rounds: each round takes those that use none of the others left, in their order. +/// → (those, the rest, which use each other in a loop or use one that does, in their order) +fn use_order(defs: Vec>) -> (Vec>, Vec>) { + let uses = uses_of(&defs); + // The round of each definition; `None` for one in a loop or one that uses such a definition. + let mut round: Vec> = vec![None; defs.len()]; + // A group outside a loop holds one definition, and comes after the groups it uses. + for (group, looped) in components(&uses) { + for v in group.into_iter().filter(|_| !looped) { + let r = uses[v].iter().try_fold(0, |r, &w| round.get(w).copied().flatten().map(|k| r.max(k + 1))); + round[v] = r; + } + } + let mut ready: Vec<(usize, usize)> = round.iter().enumerate().filter_map(|(i, r)| r.map(|r| (r, i))).collect(); + ready.sort_unstable(); + let order = ready.into_iter().filter_map(|(_, i)| defs.get(i).copied()).collect(); + let rest = defs.iter().zip(&round).filter(|(_, r)| r.is_none()).map(|(def, _)| *def).collect(); + (order, rest) +} + +/// The positions in `defs` of the definitions each one uses, itself included. +fn uses_of(defs: &[Def<'_>]) -> Vec> { + let mut at: BTreeMap<(Res, &str), Vec> = BTreeMap::new(); + for (i, def) in defs.iter().enumerate() { + at.entry(def.key()).or_default().push(i); + } + defs.iter() + .map(|def| { + let mut to = vec![]; + def.each_use(&mut |res, n| to.extend(at.get(&(res, n)).into_iter().flatten())); + to.sort_unstable(); + to.dedup(); + to + }) + .collect() +} + +/// `rest` ([`use_order`]'s definitions that use each other in a loop or use one that does) split +/// into its loops and the definitions outside a loop, each group after the groups it uses: +/// (positions in `rest`, in their order; true for a loop, definitions that use each other or one +/// that uses itself). +fn loop_groups(rest: &[Def<'_>]) -> Vec<(Vec, bool)> { + components(&uses_of(rest)) +} + +/// The strongly connected components of the graph `uses` (each node's successors), each after +/// those it reaches: (nodes, in their order; true for a loop, two or more nodes or one that uses +/// itself). +fn components(uses: &[Vec]) -> Vec<(Vec, bool)> { + // Tarjan's algorithm, with an explicit stack of calls. + const NONE: usize = usize::MAX; + let (mut index, mut low, mut on) = (vec![NONE; uses.len()], vec![0; uses.len()], vec![false; uses.len()]); + let (mut stack, mut next, mut groups) = (vec![], 0, vec![]); + for root in 0..uses.len() { + if index[root] != NONE { + continue; + } + let mut calls: Vec<(usize, usize)> = vec![(root, 0)]; + (index[root], low[root], on[root]) = (next, next, true); + next += 1; + stack.push(root); + while let Some(&(v, k)) = calls.last() { + if let Some(&w) = uses[v].get(k) { + if let Some(top) = calls.last_mut() { + top.1 += 1; + } + if index[w] == NONE { + (index[w], low[w], on[w]) = (next, next, true); + next += 1; + stack.push(w); + calls.push((w, 0)); + } else if on[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + calls.pop(); + if let Some(&(u, _)) = calls.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == index[v] { + let mut group = vec![]; + while let Some(w) = stack.pop() { + on[w] = false; + group.push(w); + if w == v { + break; + } + } + group.sort_unstable(); + let looped = group.len() > 1 || uses[v].contains(&v); + groups.push((group, looped)); + } + } + } + groups +} + +/// The names in `d` that `looped` (a loop of definitions that use each other, [`loop_groups`]) +/// all have at once, each the same once relinked by `r` with those names: each one's name or a +/// numbered copy of it. The names are picked one definition at a time, in order, and each +/// definition is compared as soon as the names of those it uses are picked, so a pick that does +/// not fit is dropped with every combination that follows from it; at most 65,536 comparisons are +/// made. `None` when no combination fits, or none was found within that many comparisons. The brush +/// library of `d` is read once, the first time a brush is compared. +fn names_of_loop(d: &Document, looped: &[Def<'_>], r: &Relink) -> Option> { + let library = OnceCell::new(); + let brushes = Brushes::Lazy(d, &library); + let names: Vec> = looped.iter().map(|def| def.names_in(d, &brushes)).collect(); + if names.is_empty() || names.iter().any(Vec::is_empty) { + return None; + } + // The definitions compared once the name at each position is picked. + let mut compare_at: Vec> = vec![vec![]; looped.len()]; + for (i, uses) in uses_of(looped).iter().enumerate() { + compare_at[uses.iter().copied().fold(i, usize::max)].push(i); + } + let (mut pick, mut guess, mut budget, mut k) = (vec![0usize; looped.len()], r.clone(), 1usize << 16, 0); + loop { + let (res, own) = looped[k].key(); + guess.renames.entry(res).or_default().insert(own.to_string(), names[k][pick[k]].clone()); + let fits = compare_at[k].iter().all(|&i| { + budget = budget.saturating_sub(1); + looped[i].same_in(d, &brushes, &names[i][pick[i]], &guess) + }); + if fits && k + 1 == looped.len() { + return Some(names.iter().zip(&pick).map(|(l, &p)| l[p].clone()).collect()); + } + if budget == 0 { + return None; + } + if fits { + k += 1; + pick[k] = 0; + continue; + } + // The next name at this position, or back to the position before. + loop { + pick[k] += 1; + if pick[k] < names[k].len() { + break; + } + k = k.checked_sub(1)?; + } + } +} + /// What pasting did to the document's resources (part of the paste result). #[derive(Debug, Default)] pub(crate) struct Imported { relink: Relink, + /// Whether relinking can change a pasted copy: a resource has another name or color in the + /// document, or the objects link a graphic style. + relinks: bool, /// Resources added to the document (renamed ones included). pub added: usize, /// Swatch conflicts resolved by merging. @@ -690,7 +933,9 @@ impl Imported { } /// Point a pasted copy at the document's resources. pub(crate) fn apply(&self, n: &mut Node) { - self.relink.node(n); + if self.relinks { + self.relink.node(n); + } } } @@ -699,6 +944,12 @@ impl Clipboard { pub(crate) fn gather(src: &Document, nodes: Vec) -> Self { let library = OnceCell::new(); let used = Used::of(&Defs::of(src, &library), &nodes); + Self::with_used(src, &used, &library, nodes) + } + + /// `nodes` and the resources of `src` that `used` names (`library`: the brush library of `src`, + /// read when a brush was used). + fn with_used(src: &Document, used: &Used, library: &OnceCell>, nodes: Vec) -> Self { let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::>(); // Read when a brush was used. let brushes = match (used.names.get(&Res::Brush), library.get()) { @@ -752,61 +1003,158 @@ impl Clipboard { /// Add the resources the objects use to `d` (inside the paste's undo step) and work out how /// pasted copies relink to them. pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported { - let used = self.used(); + self.import_used(d, &self.used(), choices, same_doc, false) + } + + /// [`Self::import_into`] for the resources `used` names. The character and paragraph styles + /// come before the symbols and patterns, whose art may set type in them; with `in_use_order`, + /// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses + /// ([`use_order`]), so that it is compared and relinked with their names here, and each loop + /// of definitions that use each other is matched on its own ([`loop_groups`]). + fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported { let mut out = Imported::default(); - self.import_images(d, &used, same_doc, &mut out); - self.import_swatches(d, &used, choices, same_doc, &mut out); + self.import_images(d, used, same_doc, &mut out); + self.import_swatches(d, used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art - // they may brush; the others once those are in. - let mut added_brushes: HashSet = self.import_brushes(d, &used, same_doc, false, &mut out).into_iter().collect(); - // Character and paragraph styles (their colours relinked first). - for (r, mine, theirs) in [(Res::CharStyle, &self.char_styles, &mut d.char_styles), (Res::ParaStyle, &self.para_styles, &mut d.para_styles)] { - for def in mine.iter().filter(|s| used.has(r, &s.name)) { - let mut s = def.clone(); + // they may brush; the others once those are in. With `in_use_order`, the brushes are + // placed in use order with the others. + let mut added_brushes: HashSet = + if in_use_order { HashSet::new() } else { self.import_brushes(d, used, same_doc, false, &mut out).into_iter().collect() }; + let mut defs: Vec> = vec![]; + for (r, list) in [(Res::CharStyle, &self.char_styles), (Res::ParaStyle, &self.para_styles)] { + defs.extend(list.iter().filter(|s| used.has(r, &s.name)).map(|s| Def::Style(r, s))); + } + defs.extend(self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)).map(|s| Def::Symbol(s, self.symbol_options.get(&s.name)))); + defs.extend(self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)).map(Def::Pattern)); + if in_use_order { + defs.extend(self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).map(Def::Brush)); + } + // Groups to place in order; true for a loop of definitions that use each other. + let groups: Vec<(Vec>, bool)> = if in_use_order { + let (order, rest) = use_order(defs); + let loops = loop_groups(&rest).into_iter().map(|(at, looped)| (at.into_iter().filter_map(|i| rest.get(i).copied()).collect(), looped)); + std::iter::once((order, false)).chain(loops).collect() + } else { + vec![(defs, false)] + }; + // Styles (their colors relinked first), symbols and patterns (compared once relinked, + // symbols with their options too). The symbols and patterns added are relinked at the end, + // when every new name and graphic style id is known; with `in_use_order`, so are the + // styles added, which a loop of uses may place before a pattern they paint with. + let (mut added_symbols, mut added_patterns, mut added_styles) = (HashSet::new(), HashSet::new(), vec![]); + // Each definition, whether it is in a loop, and the loop it starts. + let steps = groups + .iter() + .flat_map(|(group, looped)| group.iter().enumerate().map(move |(i, def)| (*def, *looped, (*looped && i == 0).then_some(group)))); + // In a loop the document does not have all at once, every definition uses one that is + // added under a free name, so each one is added too. + let mut fresh = false; + // The names in the document of the loop being placed, when it has them all at once. + let mut matched: BTreeMap<(Res, String), String> = BTreeMap::new(); + // The document's brush library, read the first time a brush is placed in use order and + // stored again after each brush added; nothing else in the loop changes it. + let mut library: Option> = None; + for (def, looped, starts) in steps { + fresh &= looped; + if !looped { + matched.clear(); + } + // A loop of uses that the document has all at once is known under those names before + // its definitions are compared. + if let Some(group) = starts { + matched.clear(); + match names_of_loop(d, group, &out.relink) { + Some(names) => { + for (def, name) in group.iter().zip(names) { + let (res, own) = def.key(); + if own != name { + out.relink.renames.entry(res).or_default().insert(own.to_string(), name.clone()); + } + matched.insert((res, own.to_string()), name); + } + fresh = false; + } + None => fresh = true, + } + } + // A definition of such a loop is the document's under the name found for it. + let (res, own) = def.key(); + if let Some(name) = matched.get(&(res, own.to_string())) { + let p = if name == own { Placed::Existing } else { Placed::Reused(name.clone()) }; + out.placed(res, own, &p); + continue; + } + match def { + Def::Style(r, def) => { + let mut s = def.clone(); + out.relink.attrs(&mut s.attrs); + let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; + let p = place(theirs, &s, same_doc, |a, b| !fresh && a == b); + if in_use_order && let Some(name) = p.new_name(&def.name) { + added_styles.push((r, name, def)); + } + out.placed(r, &def.name, &p); + } + Def::Symbol(def, options) => { + let theirs: HashMap = match options { + Some(_) => d + .symbols + .iter() + .filter(|s| copy_of(&def.name, &s.name)) + .map(|s| (s.name.clone(), SymbolOptions::of(d, &s.name))) + .collect(), + None => HashMap::new(), + }; + let r = &out.relink; + let p = place(&mut d.symbols, def, same_doc, |a, b| { + !fresh + && options.is_none_or(|o| theirs.get(&a.name) == Some(o)) + && same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art)) + }); + if let Some(name) = p.new_name(&def.name) { + if let Some(o) = options { + o.give(d, &name); + } + added_symbols.insert(name); + } + out.placed(Res::Symbol, &def.name, &p); + } + Def::Pattern(def) => { + // Tile options alike, and the art once relinked. + let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; + let same = |n: &str| !fresh && d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art)); + let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| { + // Its swatch comes along under the same name. + let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n)); + add_pattern(d, PatternDef { name: name.clone(), ..def.clone() }); + Placed::added(&def.name, name) + }); + added_patterns.extend(p.new_name(&def.name)); + out.placed(Res::Pattern, &def.name, &p); + } + Def::Brush(b) => { + let lib = library.get_or_insert_with(|| vectorcraft_brush::library(d)); + let r = &out.relink; + let p = place(lib, b, same_doc, |ours, theirs| !fresh && same_brush(r, ours, theirs)); + if let Some(name) = p.new_name(&b.name) { + vectorcraft_brush::store(d, lib); + added_brushes.insert(name); + } + out.placed(Res::Brush, &b.name, &p); + } + } + } + if !in_use_order { + added_brushes.extend(self.import_brushes(d, used, same_doc, true, &mut out)); + } + self.import_graphic_styles(d, used, same_doc, &mut out); + for (r, name, def) in added_styles { + let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; + if let Some(s) = theirs.iter_mut().find(|s| s.name == name) { + s.attrs = def.attrs.clone(); out.relink.attrs(&mut s.attrs); - let p = place(theirs, &s, same_doc, PartialEq::eq); - out.placed(r, &def.name, &p); } } - // Symbols and patterns, compared once relinked (symbols with their options too). Those - // added are relinked at the end, when every new name and graphic style id is known. - let mut added_symbols = HashSet::new(); - for def in self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)) { - let options = self.symbol_options.get(&def.name); - let theirs: HashMap = match options { - Some(_) => { - d.symbols.iter().filter(|s| copy_of(&def.name, &s.name)).map(|s| (s.name.clone(), SymbolOptions::of(d, &s.name))).collect() - } - None => HashMap::new(), - }; - let r = &out.relink; - let p = place(&mut d.symbols, def, same_doc, |a, b| { - options.is_none_or(|o| theirs.get(&a.name) == Some(o)) && same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art)) - }); - if let Some(name) = p.new_name(&def.name) { - if let Some(o) = options { - o.give(d, &name); - } - added_symbols.insert(name); - } - out.placed(Res::Symbol, &def.name, &p); - } - let mut added_patterns = HashSet::new(); - for def in self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)) { - // Tile options alike, and the art once relinked. - let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; - let same = |n: &str| d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art)); - let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| { - // Its swatch comes along under the same name. - let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n)); - add_pattern(d, PatternDef { name: name.clone(), ..def.clone() }); - Placed::added(&def.name, name) - }); - added_patterns.extend(p.new_name(&def.name)); - out.placed(Res::Pattern, &def.name, &p); - } - added_brushes.extend(self.import_brushes(d, &used, same_doc, true, &mut out)); - self.import_graphic_styles(d, &used, same_doc, &mut out); for s in d.symbols.iter_mut().filter(|s| added_symbols.contains(&s.name)) { out.relink.node(Arc::make_mut(&mut s.art)); } @@ -822,6 +1170,7 @@ impl Clipboard { } vectorcraft_brush::store(d, &lib); } + out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty()); out } @@ -919,6 +1268,43 @@ impl Clipboard { } } +/// Where a loaded text style is in the document ([`import_text_styles`]). +pub(crate) struct LoadedStyle { + /// A paragraph style (else a character style). + pub para: bool, + /// Its name in the document. + pub name: String, + /// It was added (else the document's own was the same). + pub added: bool, +} + +/// Add every character style of `src` (with `chars`) and every paragraph style (with `paras`) to +/// `d`, with the swatches, patterns and what their art uses (styles of the other kind included) +/// that the styles' fill and stroke lead to, as Paste adds those of pasted type +/// ([`Clipboard::import_into`]; a global or spot color whose name `d` gives another color is +/// `d`'s). Each style, symbol and pattern is placed after those it uses, so that a style follows +/// a pattern added under a free name and a pattern follows a style renamed on the way in. A style +/// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is +/// used; any other is added under a free name. → each style of `src` placed, in order, character +/// styles first. +pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec { + let library = OnceCell::new(); + let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras); + let clip = Clipboard::with_used(src, &used, &library, vec![]); + let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::>()); + let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true); + let mut placed: Vec = vec![]; + for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] { + for s in list { + let name = out.relink.renamed(r, &s.name); + let again = placed.iter().any(|p| p.para == para && p.name == name); + let added = !again && !before[usize::from(para)].contains(&name); + placed.push(LoadedStyle { para, name, added }); + } + } + placed +} + /// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups /// share one namespace). fn add_swatch(d: &mut Document, w: &Swatch, same_doc: bool) -> Placed { diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 9842cfbe9..7bdcfad58 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -19,7 +19,7 @@ use vectorcraft_render::proof; #[cfg(not(target_arch = "wasm32"))] use super::EngineError; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, color_value, has_doc, str_param, targets}; +use super::{CommandSpec, Result, Session, always, bad, cmd, color_value, has_doc, switch_or, targets, text_param}; /// `Document.unknown` key under which files before format v3 kept the assigned profiles (migrated /// to `Document::color_profiles` on load). @@ -100,7 +100,7 @@ pub fn specs() -> Vec { proof_setup ), cmd!(query "view.proofColors", "Proof Colors", ["View"], None, "{on?: bool (default: toggle)} → proof state", always, |s, p| { - toggle_view(s, p, |v, on| v.proof_colors = on.unwrap_or(!v.proof_colors)) + toggle_view(s, p, "view.proofColors", |v, on| v.proof_colors = on.unwrap_or(!v.proof_colors)) }), cmd!( query "view.overprintPreview", @@ -109,7 +109,7 @@ pub fn specs() -> Vec { Some("Cmd+Alt+Shift+Y"), "{on?: bool (default: toggle)} → proof state", always, - |s, p| toggle_view(s, p, |v, on| v.overprint = on.unwrap_or(!v.overprint)) + |s, p| toggle_view(s, p, "view.overprintPreview", |v, on| v.overprint = on.unwrap_or(!v.overprint)) ), cmd!( query "view.separationsPreview", @@ -143,12 +143,21 @@ pub fn specs() -> Vec { } fn intent_param(p: &Value, cmd: &str) -> Result> { - match str_param(p, "intent") { + match text_param(p, "intent", cmd)? { None => Ok(None), Some(i) => Intent::parse(i).map(Some).ok_or_else(|| bad(cmd, format!("unknown intent `{i}`"))), } } +/// The true or false `p` gives for `key`, or `None` when `p` leaves it out or gives null. Any other +/// value is an error, as [`switch_or`] reads it. +fn on_param(p: &Value, key: &str, cmd: &str) -> Result> { + if p.get(key).is_none_or(Value::is_null) { + return Ok(None); + } + switch_or(p, key, false, cmd).map(Some) +} + fn settings_json(st: &ColorSettings) -> Value { json!({ "rgb": st.rgb, @@ -171,16 +180,16 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.colorSettings"; let mut st = cms::active_settings(); let before = st.clone(); - if let Some(v) = str_param(p, "rgb") { + if let Some(v) = text_param(p, "rgb", C)? { st.rgb = cms::canonical_name(v).to_string(); } - if let Some(v) = str_param(p, "cmyk") { + if let Some(v) = text_param(p, "cmyk", C)? { st.cmyk = cms::canonical_name(v).to_string(); } if let Some(i) = intent_param(p, C)? { st.intent = i; } - st.bpc = bool_or(p, "bpc", st.bpc); + st.bpc = switch_or(p, "bpc", st.bpc, C)?; if st != before { cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; touch_all(s); @@ -191,7 +200,7 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn load_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "color.loadProfile"; - let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + let path = text_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path`"))?; crate::file_access::check_read(path).map_err(EngineError::Other)?; let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?; let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?; @@ -209,7 +218,7 @@ fn load_profile(s: &mut Session, p: &Value) -> Result { /// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it. fn remove_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "color.removeProfile"; - let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing `name`"))?; let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?; if info.builtin { return Err(bad(C, format!("`{name}` is a built-in profile and stays"))); @@ -289,7 +298,7 @@ pub enum Grays { impl Grays { /// The `grays` param of `cmd`: `"profile"` (default) or `"black"`. pub(crate) fn param(cmd: &str, p: &Value) -> Result { - match str_param(p, "grays").map(str::to_ascii_lowercase).as_deref() { + match text_param(p, "grays", cmd)?.map(str::to_ascii_lowercase).as_deref() { None | Some("profile") => Ok(Grays::Profile), Some("black") => Ok(Grays::Black), Some(g) => Err(bad(cmd, format!("grays must be \"profile\" or \"black\", not `{g}`"))), @@ -302,18 +311,18 @@ impl Grays { /// colour through the colour settings. pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result { const C: &str = "file.documentColorMode"; - let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() { + let mode = match p.get("mode").and_then(Value::as_str).map(str::to_ascii_lowercase).as_deref() { Some("cmyk") => ColorMode::Cmyk, Some("rgb") => ColorMode::Rgb, _ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")), }; let intent = intent_param(p, C)?; let grays = Grays::param(C, p)?; + let convert = switch_or(p, "convert", true, C)?; // Choosing the mode the document already has (the checked menu item) is not an edit. if s.doc()?.doc.color_mode == mode { return Ok(json!({ "changed": 0 })); } - let convert = bool_or(p, "convert", true); let mut changed = 0usize; s.edit("Document Color Mode", |d, _| { changed = set_color_mode(d, mode, convert, intent, grays); @@ -654,7 +663,7 @@ fn convert_color(_: &mut Session, p: &Value) -> Result { let c = cms::active(); let intent = intent_param(p, C)?.unwrap_or(c.settings().intent); let lab = c.lab(&col); - let (model, vals) = match str_param(p, "to").unwrap_or("rgb") { + let (model, vals) = match text_param(p, "to", C)?.unwrap_or("rgb") { "rgb" => ("rgb", values(&c.convert(&col, Model::Rgb, intent))), "cmyk" => ("cmyk", values(&c.convert(&col, Model::Cmyk, intent))), "gray" | "grayscale" => ("gray", values(&c.convert(&col, Model::Gray, intent))), @@ -674,7 +683,11 @@ fn convert_color(_: &mut Session, p: &Value) -> Result { fn gamut_check(s: &mut Session, p: &Value) -> Result { let mut cols: Vec = vec![]; - if let Some(a) = p.get("colors").and_then(Value::as_array) { + let colors = p.get("colors").filter(|v| !v.is_null()); + if let Some(v) = colors.filter(|v| !v.is_array()) { + return Err(bad("color.gamutCheck", format!("`colors` must be an array of colors, not {v}"))); + } + if let Some(a) = colors.and_then(Value::as_array) { for v in a { cols.push(color_value(v).ok_or_else(|| bad("color.gamutCheck", format!("bad color {v}")))?); } @@ -728,7 +741,7 @@ fn view_json(v: &proof::ProofView) -> Value { fn proof_setup(s: &mut Session, p: &Value) -> Result { const C: &str = "view.proofSetup"; let mut v = proof::view(); - if let Some(t) = str_param(p, "target") { + if let Some(t) = text_param(p, "target", C)? { let t = ProofTarget::parse(t).ok_or_else(|| bad(C, format!("unknown proof target `{t}`")))?; if let ProofTarget::Cmyk(name) = &t && !cms::profile(name).is_some_and(|k| k.kind == cms::ProfileKind::Cmyk) @@ -740,8 +753,8 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { if let Some(i) = intent_param(p, C)? { v.setup.intent = i; } - v.setup.simulate_paper = bool_or(p, "simulatePaper", v.setup.simulate_paper); - if let Some(on) = p.get("proof").and_then(Value::as_bool) { + v.setup.simulate_paper = switch_or(p, "simulatePaper", v.setup.simulate_paper, C)?; + if let Some(on) = on_param(p, "proof", C)? { v.proof_colors = on; } proof::set_view(v.clone()); @@ -749,9 +762,10 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { Ok(view_json(&v)) } -fn toggle_view(s: &mut Session, p: &Value, f: impl FnOnce(&mut proof::ProofView, Option)) -> Result { +fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option)) -> Result { + let on = on_param(p, "on", cmd)?; let mut v = proof::view(); - f(&mut v, p.get("on").and_then(Value::as_bool)); + f(&mut v, on); proof::set_view(v.clone()); touch_all(s); Ok(view_json(&v)) @@ -779,16 +793,23 @@ fn separations(s: &mut Session, p: &Value) -> Result { .map(|d| proof::plates(&d.doc).into_iter().map(|p| p.name).collect()) .unwrap_or_else(|| cms::PROCESS_PLATES.iter().map(|s| s.to_string()).collect()); let check = |n: &str| if all.iter().any(|a| a == n) { Ok(n.to_string()) } else { Err(bad(C, format!("unknown plate `{n}`"))) }; - if let Some(on) = p.get("on").and_then(Value::as_bool) { + if let Some(on) = on_param(p, "on", C)? { v.separations = if on { Some(v.separations.take().unwrap_or_else(|| all.clone())) } else { None }; } - if let Some(a) = p.get("plates").and_then(Value::as_array) { - v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::>>()?); + match p.get("plates") { + None | Some(Value::Null) => {} + Some(Value::Array(a)) if a.iter().all(Value::is_string) => { + v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::>>()?); + } + Some(_) => { + let key = "plates"; + return Err(bad(C, format!("`{key}` must be an array of these names: {}", all.join(", ")))); + } } - if let Some(n) = str_param(p, "only") { + if let Some(n) = text_param(p, "only", C)? { v.separations = Some(vec![check(n)?]); } - if let Some(n) = str_param(p, "toggle") { + if let Some(n) = text_param(p, "toggle", C)? { let n = check(n)?; let vis = v.separations.get_or_insert_with(|| all.clone()); if let Some(i) = vis.iter().position(|x| *x == n) { @@ -804,8 +825,8 @@ fn separations(s: &mut Session, p: &Value) -> Result { fn set_spot(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.setSpot"; - let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); - let spot = bool_or(p, "spot", true); + let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing `name`"))?.to_string(); + let spot = switch_or(p, "spot", true, C)?; let sw = s.doc()?.doc.swatch(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; if !matches!(sw.paint, Paint::Solid { .. }) { return Err(bad(C, "only solid-colour swatches can be spot colours")); diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index f63e0262b..f39aba2b1 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -195,6 +195,64 @@ fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> { Ok(()) } +/// A dimension whose live link [`end_links`] ended, as it was before. +pub(crate) struct EndedLink { + id: NodeId, + recipe: Dimension, + art: Vec>, +} + +/// The dimensions under `roots` or holding one of them. A dimension can be listed more than once. +pub(crate) fn reached(doc: &Document, roots: &[NodeId]) -> Vec { + let mut ids = Vec::new(); + for n in roots.iter().filter_map(|r| doc.node(*r)) { + n.walk(&mut |c| { + if c.extras().dimension.is_some() { + ids.push(c.id); + } + }); + if doc.dimensions { + let holders = doc.ancestry(n.id).unwrap_or_default(); + ids.extend(holders.into_iter().filter(|id| doc.node(*id).is_some_and(|h| h.extras().dimension.is_some()))); + } + } + ids +} + +impl EndedLink { + /// Is the art this dimension measures, changed from `before` to `after`, still art it can link to? + pub(crate) fn source_kept(&self, before: &Document, after: &Document) -> bool { + !self.recipe.source.as_ref().is_some_and(|s| s.topology_changed(before, after)) && self.recipe.clone().resolve(after).is_ok() + } +} + +/// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it +/// measured. A command that replaces art calls this before it changes anything, since replacing +/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`]. +pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec { + let mut ended = Vec::new(); + for id in reached(doc, roots) { + let Some(n) = doc.node_mut(id) else { continue }; + let art = n.children().cloned().unwrap_or_default(); + if let Some(recipe) = n.extras_mut().dimension.take() { + ended.push(EndedLink { id, recipe: *recipe, art }); + } + } + ended +} + +/// The dimensions in `ended` whose art is as it was are live again. +pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec) { + for e in ended { + if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) + && let Some(n) = doc.node_mut(e.id) + { + n.extras_mut().dimension = Some(Box::new(e.recipe)); + doc.dimensions = true; + } + } +} + /// Called inside every edit, so dimensions and their undo history stay in step with the art they /// measure. Returns at once in documents without dimensions. pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result { diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index df55e5207..dbca59b00 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -14,7 +14,7 @@ pub(super) fn curvature(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "need at least one point")); } let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?); - if let Some(id) = id_param(p, "id") { + if let Some(id) = crate::cmd::object_param(p, "id", C)? { s.edit("Curvature", |d, sel| { *path_mut(d, id)? = PathData::single(sp); sel.set([id]); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f4b2d21d2..dbe3d4509 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -9,9 +9,9 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; use super::{ - CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, cmd, f64_or, f64_req, - finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index, ok, - point_param, str_param, switch_or, + CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, clock_date, cmd, date_param, + f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, + index, ok, point_param, str_param, switch_or, }; use crate::{Clipboard, EngineError, inspect}; @@ -116,6 +116,15 @@ pub fn specs() -> Vec { can_clear_history, clear_history ), + cmd!( + "edit.newDocumentFromState", + "New Document from State", + [], + None, + "{index?: a row of the History panel (0: the oldest state kept; default: the current state), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} make a new document holding the art as it was at that state, and make it the active document (the History panel's New Document from State button). The new document is named with the next Untitled-N, has nothing selected and no steps to undo or redo, is marked unsaved and links to the same files. It is out of symbol, opacity-mask and pattern editing, with the art drawn in a pattern's tile stored in the pattern as object.pattern.done stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step, as edit.undo keeps it; `index` names the row as it was listed before that. An index that names no state is an error and changes nothing → {index: the new document's, title}", + has_doc, + new_document_from_state + ), cmd!( "edit.undoGroup.begin", "Begin Undo Group", @@ -139,7 +148,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", has_selection_guides_or_artboard_tool, cut ), @@ -148,7 +157,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -606,6 +615,53 @@ fn clear_history(s: &mut Session, _: &Value) -> Result { Ok(json!({ "cleared": cleared })) } +/// History panel › New Document from State: a new document with the art of a state, and without +/// the editing state that state may hold (opacity-mask or symbol editing, Puppet Warp pins, new art). +/// Its links point at the files the source finds, which may be in the source's folder away from +/// the links' paths. Later artboards in the two documents take their ids separately. A later save of the new document +/// over the file its art was read from asks first, as a save of the source does. +fn new_document_from_state(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.newDocumentFromState"; + let created = date_param(p, "created", C)?; + let st = s.doc()?; + // The row as the panel lists it now: keeping an edit in progress below changes the rows. + let chosen = match p.get("index") { + None | Some(Value::Null) => None, + Some(v) => { + let state = v.as_u64().and_then(|row| st.history.state(&st.doc, index(row))); + Some(state.ok_or_else(|| bad(C, format!("no history state {v}")))?.clone()) + } + }; + settle_interaction(s)?; + let st = s.doc()?; + let mut doc = Document::clone(chosen.as_ref().unwrap_or(&st.doc)); + let (imported_from, import_losses, opened_by) = (st.imported_from.clone(), st.import_losses.clone(), st.opened_by.clone()); + if let Some(path) = st.path.as_deref() { + // Files are looked for within the asking caller's roots, else within those of the caller that opened the source. + let roots = crate::file_access::current().or_else(|| opened_by.clone()); + crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, path)); + } + doc.drop_edit_modes(); + if doc.pattern_edit.is_some() { + super::patterncmds::finish_edit(&mut doc, &mut vectorcraft_doc::Selection::default())?; + } + doc.puppet = None; + doc.new_art.set(Arc::default()); + let retired = vectorcraft_doc::RetiredArtboardId::default(); + retired.raise(doc.retired_artboard_id.get()); + doc.retired_artboard_id = retired; + doc.title = s.next_untitled(); + doc.metadata.created = clock_date(s, "created", created); + doc.metadata.modified = None; + let i = s.add_document(doc, None); + let st = s.doc_mut()?; + st.imported_from = imported_from; + st.import_losses = import_losses; + st.opened_by = st.opened_by.take().or(opened_by); + st.mark_unsaved(); + Ok(json!({ "index": i, "title": st.title() })) +} + /// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same. fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc { match like { @@ -701,10 +757,15 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { Ok((direct, roots)) } -fn copy(s: &mut Session, _: &Value) -> Result { +/// The params edit.copy and edit.cut pass on to `artboard.copy` and `artboard.cut`. +fn artboard_clipboard_params(p: &Value) -> Value { + p.get("lockedAndHidden").map_or_else(|| json!({}), |v| json!({ "lockedAndHidden": v })) +} + +fn copy(s: &mut Session, p: &Value) -> Result { // The Artboard tool copies its artboard (and the art on it). if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &json!({}), false); + return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); } let (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; @@ -744,7 +805,7 @@ fn copy(s: &mut Session, _: &Value) -> Result { fn cut(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &json!({}), true); + return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); } // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index a823ee161..21f43e0ee 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), 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)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool, item?: appearance item index|null (as effect.remove), 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)} → {ids}", has_doc, set_params ), @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -222,6 +222,16 @@ pub(crate) const ONE_3D: &str = "An object takes one 3D effect. Edit the existin pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.apply"; let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; + // An effect given as `id` names no objects: the targets are read with that `id` as null. + let effect_as_id; + let p = if str_param(p, "effect").is_none() { + let mut q = p.clone(); + q["id"] = Value::Null; + effect_as_id = q; + &effect_as_id + } else { + p + }; let mut params = p.get("params").cloned().unwrap_or(Value::Null); if !params.is_null() && !params.is_object() { return Err(bad(C, "`params` must be an object")); @@ -422,10 +432,28 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { return vec![]; }; let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] }; - let effect = |&(id, at, index): &(NodeId, Option, usize)| Some(st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id.clone()); + // A 3D effect whose type `threeDType` changes takes the new effect's parameters. + let kind = three_d_type(p, C).ok().flatten(); + let effect = |&(id, at, index): &(NodeId, Option, usize)| { + let id = &st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id; + Some(kind.filter(|_| effects::is_three_d(id)).map_or_else(|| id.clone(), str::to_string)) + }; targets.iter().filter_map(effect).collect() } +/// The effect `threeDType` in `p` turns a 3D effect into ([`effects::THREE_D_TYPES`]), when given. +fn three_d_type(p: &Value, cmd: &str) -> Result> { + let key = "threeDType"; + let Some(v) = p.get(key) else { return Ok(None) }; + match v.as_str().and_then(|t| effects::THREE_D_TYPES.iter().find(|(name, _)| *name == t)) { + Some((_, id)) => Ok(Some(id)), + None => { + let names: Vec<&str> = effects::THREE_D_TYPES.iter().map(|(name, _)| *name).collect(); + Err(bad(cmd, format!("`{key}` must be one of {}", names.join(", ")))) + } + } +} + fn lighting_preset(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.lightingPreset"; let patch = str_param(p, "preset") @@ -450,8 +478,9 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { } /// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and -/// sets its Warp style (`warpStyle`) or visibility (`visible`), as one undo step. They are checked -/// as `effect.apply` checks them, and a visible 3D effect must still fit. → `{ids}`. +/// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one +/// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still +/// fit. → `{ids}`. fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; @@ -459,6 +488,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if !params.is_null() && !params.is_object() { return Err(bad(C, "`params` must be an object")); } + let kind = three_d_type(p, C)?; let warp_id = p .get("warpStyle") .map(|v| -> Result { @@ -476,21 +506,41 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if edited.iter().any(|e| texture_load_effect(e)) { load_texture(&mut params, C)?; } - for (id, target, index) in effect_targets(s, p, item, index, C)? { + let targets = effect_targets(s, p, item, index, C)?; + // The type each 3D effect already has, with nothing else to change, is no edit (and is not + // checked again). + let nothing_else = params.as_object().is_none_or(serde_json::Map::is_empty) && visible.is_none() && warp_id.is_none(); + if let Some(to) = kind + && nothing_else + { + let d = &s.doc()?.doc; + // The effects edited: a target without an effect at `index` is left as it is. + let ids: Vec<&str> = + targets.iter().filter_map(|&(id, at, index)| d.node(id)?.appearance.effects_at(at)?.get(index).map(|e| e.id.as_str())).collect(); + if !ids.is_empty() && ids.iter().all(|id| *id == to) { + return Ok(json!({ "ids": [] })); + } + } + for &(id, target, index) in &targets { let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index)); if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) { return Err(bad(C, "warpStyle can only edit an applied Warp effect")); } + if kind.is_some() && 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))); + } if let Some(e) = effect && effects::is_three_d(&e.id) && visible != Some(false) { - let mut prospective = effects::merged_params(&e.id, &e.params); - if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, ¶ms)) { + let to = kind.unwrap_or(&e.id); + let mut prospective = + if to == e.id { effects::merged_params(&e.id, &e.params) } else { effects::three_d_params_as(&e.id, &e.params, to) }; + if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, ¶ms)) { cur.extend(new); } - effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?; + effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; } } let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| { @@ -498,6 +548,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if let Some(id) = &warp_id { e.id.clone_from(id); } + if let Some(to) = kind + && e.id != to + { + e.params = effects::three_d_params_as(&e.id, &e.params, to); + e.id = to.to_string(); + } if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, ¶ms), &mut e.params) { if !cur.is_object() { *cur = effects::merged_params(&e.id, &Value::Null); @@ -828,12 +884,17 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let failed = vectorcraft_render::decode_failures(); let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { + let before = d.clone(); + let mut ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut out = vec![]; let threaded = super::threads::frames(d); for id in &roots { expand_node(d, *id, &brushes, &mut out); } + // A dimension whose source was replaced stays plain: its link no longer fits that art. + ended.retain(|e| e.source_kept(&before, d)); + super::dimension::restore_unchanged(d, ended); // Outlined frames of threaded text leave their threads with the text they show; type that // stays type under a new id takes its frame's place. super::threads::leave_threads(d, &threaded); @@ -854,5 +915,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod lighting_presets_tests; +#[cfg(test)] +mod three_d_type_tests; + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/effectcmd/three_d_type_tests.rs b/crates/engine/src/cmd/effectcmd/three_d_type_tests.rs new file mode 100644 index 000000000..5fef22909 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/three_d_type_tests.rs @@ -0,0 +1,144 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +fn setup(effect: &str, params: Value) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap(); + s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":params})).unwrap(); + (s, id) +} + +fn effect(s: &Session, id: NodeId) -> (String, Value) { + let e = &s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0]; + (e.id.clone(), e.params.clone()) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn three_d_type_turns_the_effect_in_place_keeping_shared_settings_in_one_undo_step() { + let types = [("plane", "threeD.rotate"), ("extrude", "threeD.extrude"), ("revolve", "threeD.revolve"), ("inflate", "threeD.inflate")]; + for (from_type, from) in types { + for (to_type, to) in types { + if from == to { + continue; + } + let (mut s, id) = setup(from, json!({"rotationX": 12.0, "rotationY": -30.0, "rotationZ": 5.0, "ambient": 40.0, "lightIntensity": 55.0})); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let p = json!({"index": 0, "item": null, "threeDType": to_type}); + s.check_params("effect.setParams", &p).unwrap(); + s.execute("effect.setParams", &p).unwrap(); + let (now, params) = effect(&s, id); + assert_eq!(now, to, "{from_type} → {to_type}"); + assert_eq!(params["rotationX"], 12.0, "{from_type} → {to_type}"); + assert_eq!(params["rotationY"], -30.0); + assert_eq!(params["ambient"], 40.0); + assert_eq!(params["lightIntensity"], 55.0); + // Settings only the new effect takes start at its defaults; others are dropped. + let defaults = vectorcraft_render::effects::default_params(to).unwrap(); + for key in params.as_object().unwrap().keys() { + assert!(defaults.get(key).is_some(), "{from_type} → {to_type} kept `{key}`"); + } + if to == "threeD.extrude" { + assert_eq!(params["bevel"], defaults["bevel"]); + } + assert_eq!(undo_len(&s), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } + } +} + +#[test] +fn three_d_type_carries_depth_between_extrude_and_inflate_and_merges_params_after() { + let (mut s, id) = setup("threeD.extrude", json!({"depth": 80.0, "twist": 30.0, "bevel": "classic"})); + s.execute("effect.setParams", &json!({"index": 0, "threeDType": "inflate", "params": {"volume": 70.0}})).unwrap(); + let (now, params) = effect(&s, id); + assert_eq!(now, "threeD.inflate"); + assert_eq!(params["depth"], 80.0); + assert_eq!(params["twist"], 30.0); + assert_eq!(params["volume"], 70.0); + assert!(params.get("bevel").is_none()); +} + +#[test] +fn revolve_angles_outside_half_a_turn_wrap_when_the_type_leaves_revolve() { + let (mut s, id) = setup("threeD.revolve", json!({"rotationX": 270.0, "rotationY": -200.0})); + s.execute("effect.setParams", &json!({"index": 0, "threeDType": "plane"})).unwrap(); + let (_, params) = effect(&s, id); + assert_eq!(params["rotationX"], -90.0); + assert_eq!(params["rotationY"], 160.0); +} + +#[test] +fn the_current_type_alone_changes_nothing_and_records_no_undo_step() { + let (mut s, id) = setup("threeD.extrude", json!({"depth": 80.0})); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let r = s.execute("effect.setParams", &json!({"index": 0, "threeDType": "extrude"})).unwrap(); + assert_eq!(r["ids"], json!([])); + assert_eq!(undo_len(&s), history); + assert_eq!(s.doc().unwrap().doc, before); + // With other changes the edit runs as usual. + s.execute("effect.setParams", &json!({"index": 0, "threeDType": "extrude", "params": {"depth": 90.0}})).unwrap(); + assert_eq!(effect(&s, id).1["depth"], 90.0); + assert_eq!(undo_len(&s), history + 1); +} + +#[test] +fn the_current_type_with_an_object_without_effects_selected_too_records_no_undo_step() { + let (mut s, a) = setup("threeD.extrude", json!({})); + let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "aligned": true, "threeDType": "extrude"})).unwrap(); + assert_eq!(undo_len(&s), history); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn three_d_type_rejects_unknown_types_and_effects_that_are_not_3d() { + let (mut s, _) = setup("threeD.extrude", json!({})); + let before = s.doc().unwrap().doc.clone(); + for t in [json!("cube"), json!(""), json!(null), json!(2), json!("Plane"), json!("plane/../x")] { + let p = json!({"index": 0, "threeDType": t}); + let err = s.execute("effect.setParams", &p).unwrap_err().to_string(); + assert!(err.contains("must be one of plane, extrude, revolve, inflate"), "{t}: {err}"); + } + assert_eq!(s.doc().unwrap().doc, before); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + let err = s.execute("effect.setParams", &json!({"index": 1, "threeDType": "plane"})).unwrap_err().to_string(); + assert!(err.contains("needs a 3D effect at index 1"), "{err}"); + let err = s.execute("effect.setParams", &json!({"index": 5, "threeDType": "plane"})).unwrap_err().to_string(); + assert!(err.contains("no effect at index 5"), "{err}"); +} + +#[test] +fn three_d_type_turns_every_selected_object_in_one_undo_step() { + let (mut s, a) = setup("threeD.rotate", json!({})); + let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#d63333"})).unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.rotate","item":null})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let history = undo_len(&s); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "aligned": true, "threeDType": "extrude"})).unwrap(); + assert_eq!(effect(&s, a).0, "threeD.extrude"); + assert_eq!(effect(&s, b).0, "threeD.extrude"); + assert_eq!(undo_len(&s), history + 1); +} + +#[test] +fn params_for_the_new_type_pass_the_parameter_check() { + let (s, _) = setup("threeD.rotate", json!({})); + // `depth` is an Extrude & Bevel setting, checked against the effect the type makes. + s.check_params("effect.setParams", &json!({"index": 0, "threeDType": "extrude", "params": {"depth": 20.0}})).unwrap(); +} diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index cead1d4d5..d67b89a97 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -4,7 +4,8 @@ //! as Break Link to Symbol places it. A linear or radial gradient fill becomes a //! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform //! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip -//! group shaped like the object. Create Gradient Mesh shares the sampling. +//! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art +//! Expand changes becomes a plain group of that art. use std::collections::BTreeSet; use std::sync::Arc; @@ -19,6 +20,7 @@ use vectorcraft_doc::pattern::{PatternDef, place_art}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes}; +use super::dimension::EndedLink; use super::edit::selected_roots; use super::menucmds::{run_raw, squash}; use super::object::as_clipping_path; @@ -41,7 +43,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {ids}", has_selection, expand ), @@ -239,8 +241,12 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { let stroke = bool_or(p, "stroke", true); let to = GradientTo::parse(p)?; let from = s.doc()?.history.undo.len(); - let rev0 = s.doc()?.revision; let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + // Before anything expands: outlining the art a dimension measures would remove the dimension. + let before = s.doc()?.doc.clone(); + let mut ended = vec![]; + end_dimension_links(s, &mut ended)?; + let rev0 = s.doc()?.revision; // Placed documents first: their art (type, strokes, gradients) expands below. if object && e.placed { let roots = selected_roots(s)?; @@ -286,6 +292,8 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { let roots = selected_roots(s)?; s.edit("Expand", |d, _| super::patterncmds::expand_repeats(d, &roots).map(|_| ()))?; } + // The dimensions in the art those steps made. + end_dimension_links(s, &mut ended)?; if object && e.effects { // Effects that Expand Appearance can't expand are left as they are. When it refuses to // leave a process color on a grayscale image with transparency, Expand fails as a whole. @@ -331,10 +339,28 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result { if s.doc()?.revision == rev0 { return Err(EngineError::Other("Expand: nothing to expand".into())); } + if !ended.is_empty() { + // A dimension whose source Expand replaced stays plain: its link no longer fits that art. + let after = s.doc()?.doc.clone(); + ended.retain(|e| e.source_kept(&before, &after)); + s.edit("Expand", |d, _| { + super::dimension::restore_unchanged(d, std::mem::take(&mut ended)); + Ok(()) + })?; + } squash(s, from, "Expand"); Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::>() })) } +/// Ends the live link of each dimension under or holding the selection, adding it to `ended`. +fn end_dimension_links(s: &mut Session, ended: &mut Vec) -> Result<()> { + let roots = selected_roots(s)?; + if !super::dimension::reached(&s.doc()?.doc, &roots).is_empty() { + ended.extend(s.edit("Expand", |d, _| Ok(super::dimension::end_links(d, &roots)))?); + } + Ok(()) +} + fn drop_live(n: &mut Node) { if let NodeKind::Path { live, .. } = &mut n.kind { *live = None; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index b1a422dea..011d025e3 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 2b1de2eda..a1ec671aa 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -373,7 +373,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result Ok(o) => super::pdfimport::import(bytes, o, opts, format.id != "pdf"), Err(e) => Err(err(e)), }; - let read = restore_or_import(editing, || { + let mut read = restore_or_import(editing, || { // Type all from the text document and nothing that needs the PDF part: the layers // alone, the PDF part not imported. Else what was read of the layers is kept for // reading them with the PDF part. @@ -440,6 +440,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result if !read.2 { file_info = opened.as_ref().ok().map(|o| o.file_info()); } + // A restored document opens on the artboard that the editing copy beside it names as + // active, as a file read from that copy does (the restored document never holds one). + if read.2 + && let Some(private) = opened.as_ref().ok().and_then(|o| o.illustrator_data()) + { + read.0.opened_artboard = vectorcraft_eps::ai_opened_artboard(&private, &read.0); + } read } "dxf" => { diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 770c11644..74cfced2b 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "editingData", ty: "boolean", default: "true", - description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", + description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, linked images as placed files with a preview, point type, area type and type on a path as text, and symbols and their instances, with other type, other patterns, other clipping objects, other symbol instances and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", }, FormatOption { name: "aiVersion", diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 812b8ac97..785764db9 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -56,7 +56,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ name: "bleed", ty: "object", default: "null", - description: "{useDocument, top, bottom, left, right} in points: the page grows by the bleed (BleedBox) around its artboard (TrimBox)", + description: "{useDocument, top, bottom, left, right} in points: the page grows by the bleed (BleedBox) around its artboard (TrimBox); a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning", }, FormatOption { name: "output", @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", has_doc, export_pdf ), @@ -257,6 +257,10 @@ pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result { pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = "Preserve editing was left out: it needs a PDF of every artboard, in order (reopened, this one would show the others too)"; +/// The warning for a PDF/X file that leaves out the document's bleed +/// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). +pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; + /// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). @@ -299,6 +303,10 @@ pub(super) fn encode_carrying( opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?; } let mut warnings = vec![]; + // The status bar shows the first warning, so this one goes ahead of the flattening note. + if opts.settings.leaves_out_bleed(doc) { + warnings.push(BLEED_LEFT_OUT.to_string()); + } if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { opts.native = Some(native()?); diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 5d2663910..9550cf90f 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{ColorMode, Document}; use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs}; use super::super::colormgmt::Grays; -use super::super::{Result, Session, bad, f64_or, parse_range, str_param}; +use super::super::{Result, Session, bad, check_numbers, number_or, parse_range, text_param}; use super::{err, source}; /// The `document.open` options besides the file. The PDF ones (pages, box, password, text, @@ -75,16 +75,17 @@ impl LoadOptions { Some(Value::Number(n)) => Some(n.to_string()), Some(v) => return Err(bad(cmd, format!("pages must be a range such as \"2-3\" or a page number, not {v}"))), }; - let crop = choice(cmd, "cropTo", str_param(p, "cropTo").or_else(|| str_param(p, "crop")))?; - let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string); - let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() { + let (crop_to, crop_alias) = (text_param(p, "cropTo", cmd)?, text_param(p, "crop", cmd)?); + let crop = choice(cmd, "cropTo", crop_to.or(crop_alias))?; + let password = text_param(p, "password", cmd)?.filter(|s| !s.is_empty()).map(str::to_string); + let color_mode = match text_param(p, "colorMode", cmd)?.map(str::to_ascii_lowercase).as_deref() { None => None, Some("rgb") => Some(ColorMode::Rgb), Some("cmyk") => Some(ColorMode::Cmyk), Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))), }; let grays = Grays::param(cmd, p)?; - let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?; + let text_as = choice(cmd, "textAs", text_param(p, "textAs", cmd)?)?; let layers = match p.get("layers").filter(|v| !v.is_null()) { None => true, Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?, @@ -172,6 +173,7 @@ const INFO: &str = "document.pdfInfo"; pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result { let bytes = source(p, INFO)?.bytes; let o = LoadOptions::from_params(INFO, p)?; + check_numbers(p, &["thumbnailSize"], INFO)?; // Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them. if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) { return postscript_info(&bytes, p); @@ -229,7 +231,7 @@ fn postscript_info(bytes: &[u8], p: &Value) -> Result { /// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer /// side, base64. fn thumbnail(doc: &Document, p: &Value) -> Result { - let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0); + let size = number_or(p, "thumbnailSize", 160.0, INFO)?.clamp(16.0, 1024.0); let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?; let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?; Ok(vectorcraft_format::base64_encode(&png)) diff --git a/crates/engine/src/cmd/fileio/progress.rs b/crates/engine/src/cmd/fileio/progress.rs index d2e039348..2d23ef6cd 100644 --- a/crates/engine/src/cmd/fileio/progress.rs +++ b/crates/engine/src/cmd/fileio/progress.rs @@ -14,7 +14,7 @@ use std::rc::Rc; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use vectorcraft_doc::cancel::{self, CancelToken, ProgressSink}; @@ -26,6 +26,9 @@ pub struct Progress { total: AtomicU64, /// The steps are files (Export for Screens), not artboards. files: bool, + /// The last report came from a plug-in ([`vectorcraft_plugins::host::PROGRESS_STEP`]): its + /// steps are its own, shown as a share of the work. + share: AtomicBool, } impl Progress { @@ -43,6 +46,11 @@ impl Progress { self.files } + /// Are the steps a plug-in's own, which only a share of the work done describes? + pub fn counts_share(&self) -> bool { + self.share.load(Ordering::Relaxed) + } + /// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go). pub fn counts(&self) -> (u64, u64) { let total = self.total.load(Ordering::Relaxed); @@ -68,7 +76,8 @@ impl Progress { /// Run `f` with `progress`'s token in effect on this thread and the steps reported to it. pub fn scope(progress: &Arc, f: impl FnOnce() -> R) -> R { let to = Arc::clone(progress); - let sink: ProgressSink = Rc::new(move |done, total, _| { + let sink: ProgressSink = Rc::new(move |done, total, what| { + to.share.store(what == vectorcraft_plugins::host::PROGRESS_STEP, Ordering::Relaxed); to.total.store(total, Ordering::Relaxed); to.done.store(done, Ordering::Relaxed); }); @@ -94,4 +103,14 @@ mod tests { assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{:?}", r.err()); assert!(!cancel::is_cancelled(), "the scope ended"); } + + /// A plug-in's reports count as a share of the work, not as artboards or files. + #[test] + fn a_plugin_s_reports_count_as_a_share_of_the_work() { + let p = Progress::new(); + scope(&p, || cancel::progress(1, 4, "artboard")); + assert_eq!((p.counts(), p.counts_share()), ((1, 4), false)); + scope(&p, || cancel::progress(3, 8, vectorcraft_plugins::host::PROGRESS_STEP)); + assert_eq!((p.counts(), p.counts_share()), ((3, 8), true)); + } } diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index ce00a2cfb..0eefa45c0 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1072,7 +1072,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | 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; 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", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | 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", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 26737e158..201a4b479 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -1019,11 +1019,14 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> let mut out = Output::default(); // The encoders' warnings for every file, each once. let mut warnings: Vec = Vec::new(); + // Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are + // written on a page of their own bounds, which it doesn't grow. + let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none(); let mut write = |doc: &Document, format: &str, options: &Value| -> Result> { // The steps are the files: an encoder's own steps (artboards, pages) are not reported. let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?; for w in notes { - if !warnings.contains(&w) { + if !(bleed_on_page && w == super::pdf::BLEED_LEFT_OUT) && !warnings.contains(&w) { warnings.push(w); } } diff --git a/crates/engine/src/cmd/fileio/tests_affinity.rs b/crates/engine/src/cmd/fileio/tests_affinity.rs index 70b9b992a..05ca07019 100644 --- a/crates/engine/src/cmd/fileio/tests_affinity.rs +++ b/crates/engine/src/cmd/fileio/tests_affinity.rs @@ -98,7 +98,9 @@ fn templates_and_libraries_cannot_silently_extract_preview_content() { let original = s.doc().unwrap().doc.clone(); for name in ["test.af", "renamed.svg"] { let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png()))}); - for cmd in ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary"] { + for cmd in + ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary", "charStyle.load", "paraStyle.load", "textStyle.loadAll"] + { let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains("File › Open"), "{cmd}: {e}"); } diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index e9e24e5a1..26e6bbc3d 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -148,6 +148,53 @@ fn an_ai_file_opens_with_its_active_artboard() { assert_eq!(d.opened_artboard, Some(d.artboards[1].id), "{r}"); } +/// A `.ai` or `.ait` file VectorCraft wrote reopens, restored from the document it carries, on the +/// artboard its editing copy names as active; without the copy, on the first artboard. +#[test] +fn a_restored_ai_file_opens_on_the_artboard_active_when_saved() { + let mut doc = vectorcraft_doc::Document::new(100.0, 100.0); + let first = doc.artboards[0].clone(); + for (i, id) in [(1.0, 7), (2.0, 9)] { + let x0 = i * 150.0; + doc.artboards.push(vectorcraft_doc::Artboard { id, rect: vectorcraft_geom::Rect::new(x0, 0.0, x0 + 100.0, 100.0), ..first.clone() }); + } + let mut s = Session::new(); + // The view's artboard, one the document doesn't have, none, and the first. + for (active, opened, reopened) in + [(Some(7), None, Some(7)), (Some(99), Some(9), Some(9)), (None, Some(9), Some(9)), (Some(first.id), Some(9), None)] + { + for (cmd, format) in [("document.save", "ai"), ("file.saveAsTemplate", "ait")] { + let mut d = doc.clone(); + d.opened_artboard = opened; + s.add_document(d, None); + s.doc_mut().unwrap().active_artboard = active; + let saved = s.execute(cmd, &json!({"format": format})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + let r = open(&mut s, &format!("again.{format}"), &bytes, json!({})).unwrap(); + assert_eq!(r["restored"], json!(true), "{r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards.iter().map(|a| a.id).collect::>(), [first.id, 7, 9]); + assert_eq!(d.opened_artboard, reopened, "{format} {active:?} {opened:?}"); + } + } + // A file saved without its PDF part still has the editing copy. + s.add_document(doc.clone(), None); + s.doc_mut().unwrap().active_artboard = Some(7); + let saved = s.execute("document.save", &json!({"format": "ai", "options": {"pdfCompatible": false}})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap(); + assert_eq!(r["restored"], json!(true), "{r}"); + assert_eq!(s.doc().unwrap().doc.opened_artboard, Some(7), "{r}"); + // Without the editing copy, the file reopens on the first artboard. + s.add_document(doc.clone(), None); + s.doc_mut().unwrap().active_artboard = Some(7); + let saved = s.execute("document.save", &json!({"format": "ai", "options": {"editingData": false}})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap(); + assert_eq!(r["restored"], json!(true), "{r}"); + assert_eq!(s.doc().unwrap().doc.opened_artboard, None, "{r}"); +} + /// A `.ai` save names the view's active artboard in its editing copy, else the one the document /// opened on, else the first; the file read from that copy opens on it. #[test] diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 605f82967..a38e35e82 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -488,7 +488,7 @@ fn an_ai_file_opens_from_its_vectorcraft_document_when_the_editing_copy_differs( let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap(); - s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [id]})).unwrap(); let r = s.execute("document.export", &json!({"format": "ai"})).unwrap(); assert!(r["warnings"].to_string().contains("live effects"), "{r}"); let bytes = b64(&r); diff --git a/crates/engine/src/cmd/fileio/tests_pdfimport.rs b/crates/engine/src/cmd/fileio/tests_pdfimport.rs index f270c87b8..5ae8bd640 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfimport.rs @@ -147,6 +147,76 @@ fn load_options_read_pages_crop_and_password() { assert_eq!(LoadOptions::from_params("x", &Value::Null).unwrap(), LoadOptions::default()); } +/// `cropTo` (and `crop`), `password`, `colorMode` and `textAs` given as anything but a string are an +/// error naming the param: `document.open` opens nothing and `file.place` places nothing and records +/// no undo step. Null is the same as leaving the param out. +#[test] +fn pdf_options_of_the_wrong_type_are_refused() { + let bytes = three_pages(); + let wrong = [ + ("cropTo", json!(5)), + ("crop", json!(true)), + ("password", json!(123)), + ("colorMode", json!(5)), + ("textAs", json!(true)), + ("textAs", json!(["outlines"])), + ]; + let one = |key: &str, v: &Value| { + let mut p = json!({}); + p[key] = v.clone(); + p + }; + let mut s = Session::new(); + for (key, v) in &wrong { + let e = open(&mut s, &bytes, one(key, v)).unwrap_err().to_string(); + assert!(e.contains(&format!("`{key}` must be a string")), "{key}: {e}"); + assert!(s.active().is_none(), "{key}: nothing opened"); + } + // A wrong `cropTo` is refused even when `crop` is a box, and a wrong `crop` when `cropTo` is. + let e = open(&mut s, &bytes, json!({"cropTo": 2, "crop": "art"})).unwrap_err().to_string(); + assert!(e.contains("`cropTo` must be a string"), "{e}"); + let e = open(&mut s, &bytes, json!({"cropTo": "art", "crop": 2})).unwrap_err().to_string(); + assert!(e.contains("`crop` must be a string"), "{e}"); + assert!(s.active().is_none(), "nothing opened"); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let nodes = s.doc().unwrap().doc.node_count(); + for (key, v) in &wrong { + let mut p = json!({"name": "pages.pdf", "dataBase64": b64(&bytes)}); + p[*key] = v.clone(); + let e = s.execute("file.place", &p).unwrap_err().to_string(); + assert!(e.contains(&format!("`{key}` must be a string")), "{key}: {e}"); + assert_eq!((s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.node_count()), (undo, nodes), "{key}: nothing placed"); + } + // Null is left out; valid strings read as before. + let nulls = json!({"cropTo": null, "crop": null, "password": null, "colorMode": null, "textAs": null}); + assert_eq!(LoadOptions::from_params("x", &nulls).unwrap(), LoadOptions::default()); + let o = LoadOptions::from_params("x", &json!({"cropTo": null, "crop": "Bleed", "colorMode": "CMYK", "textAs": "outlines"})).unwrap(); + assert_eq!( + (o.crop, o.color_mode, o.text_as), + (vectorcraft_pdf::CropTo::Bleed, Some(vectorcraft_doc::ColorMode::Cmyk), vectorcraft_pdf::TextAs::Outlines) + ); +} + +/// `document.pdfInfo`'s `thumbnailSize` given as anything but a number is an error, with or without +/// a `thumbnail`; null is 160 px. +#[test] +fn pdf_info_refuses_a_thumbnail_size_that_is_not_a_number() { + let bytes = three_pages(); + let mut s = Session::new(); + for v in [json!("100"), json!(true)] { + for p in [json!({"dataBase64": b64(&bytes), "thumbnail": 1, "thumbnailSize": v}), json!({"dataBase64": b64(&bytes), "thumbnailSize": v})] { + let e = s.execute("document.pdfInfo", &p).unwrap_err().to_string(); + assert!(e.contains("`thumbnailSize` must be a number"), "{v}: {e}"); + } + } + let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "colorMode": 1})).unwrap_err().to_string(); + assert!(e.contains("`colorMode` must be a string"), "{e}"); + let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "thumbnail": 1, "thumbnailSize": null})).unwrap(); + let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap(); + assert_eq!(image::load_from_memory(&png).unwrap().width(), 160); +} + #[test] fn a_postscript_ai_file_says_it_cannot_be_opened_yet() { // PostScript opens through the EPS reader now (tests_epsimport.rs): a program it can't read, diff --git a/crates/engine/src/cmd/fileio/tests_pdfx.rs b/crates/engine/src/cmd/fileio/tests_pdfx.rs index f8c3e0c59..abd1bbe4a 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfx.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfx.rs @@ -90,6 +90,55 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() { } } +/// A PDF/X file without the bleed the document sets exports as asked and warns; the presets and +/// options that take the document's bleed, and files that aren't PDF/X files, don't warn. +#[test] +fn a_pdfx_file_that_leaves_out_the_document_bleed_warns() { + let left_out = |w: &Vec| w.iter().any(|w| w == super::pdf::BLEED_LEFT_OUT); + let mut s = session(); + s.execute("document.setup", &json!({"bleed": 6})).unwrap(); + for standard in ["pdfX1a", "pdfX3", "pdfX4"] { + let (_, warnings) = pdf(&mut s, json!({"standard": standard})); + // First: the status bar shows the first warning, and PDF/X-1a and PDF/X-3 add the flattening note. + assert_eq!(warnings.first().map(String::as_str), Some(super::pdf::BLEED_LEFT_OUT), "{standard}: {warnings:?}"); + // The page has no bleed, as asked. + let v = s.execute("document.exportPdf", &json!({"standard": standard})).unwrap(); + let info = s.execute("document.pdfInfo", &json!({"name": "x.pdf", "dataBase64": v["dataBase64"]})).unwrap(); + let boxes = &info["pageInfo"][0]["boxes"]; + assert!(boxes["media"].is_array() && boxes["media"] == boxes["trim"] && boxes["media"] == boxes["bleed"], "{standard}: {info}"); + } + let v = s.execute("document.export", &json!({"format": "pdf", "standard": "pdfX4"})).unwrap(); + assert!(v["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::BLEED_LEFT_OUT), "{v}"); + // A side the settings leave out warns, and sides they all keep don't. + let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4", "bleed": {"top": 6, "bottom": 6, "left": 6}})); + assert!(left_out(&warnings), "{warnings:?}"); + for p in [ + json!({"preset": "PDF/X-1a:2001"}), + json!({"preset": "PDF/X-3:2002"}), + json!({"preset": "PDF/X-4:2010"}), + json!({"standard": "pdfX4", "bleed": {"useDocument": true}}), + json!({"standard": "pdfX4", "bleed": {"top": 1, "bottom": 1, "left": 1, "right": 1}}), + json!({}), + json!({"standard": "pdfA2b"}), + ] { + let (_, warnings) = pdf(&mut s, p.clone()); + assert!(!left_out(&warnings), "{p}: {warnings:?}"); + } + // The dialog's Summary exports in memory, so it lists the warning too. + let v = s.execute("document.pdfSettings", &json!({"standard": "pdfX4", "includeDocument": true})).unwrap(); + assert!(v["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::BLEED_LEFT_OUT), "{v}"); + // A bleed on the top alone: only that side counts. + s.execute("document.setup", &json!({"bleed": [6, 0, 0, 0]})).unwrap(); + let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4", "bleed": {"top": 1}})); + assert!(!left_out(&warnings), "{warnings:?}"); + let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4", "bleed": {"bottom": 1}})); + assert!(left_out(&warnings), "{warnings:?}"); + // A document without a bleed has nothing to leave out. + s.execute("document.setup", &json!({"bleed": 0})).unwrap(); + let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4"})); + assert!(!left_out(&warnings), "{warnings:?}"); +} + /// The ink amounts (0..1) `doc` shows at `p` in its images (the topmost image there). fn image_inks_at(doc: &vectorcraft_doc::Document, p: vectorcraft_geom::Point) -> Option<[f32; 4]> { fn visit(n: &vectorcraft_doc::Node, out: &mut Vec) { diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index f1790225a..64dd14dcf 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -526,6 +526,20 @@ fn include_bleed_grows_each_artboard() { assert_eq!(size(&files(&r)[0].1), (40, 30), "without it, the artboard alone"); } +/// Include Bleed puts the bleed on a PDF/X row's page, so the row doesn't warn that it leaves the bleed out. +#[test] +fn include_bleed_pdfx_rows_do_not_warn_about_the_bleed() { + let mut s = session(1); + s.execute("document.setup", &json!({"bleed": 6})).unwrap(); + let warned = |r: &Value| r["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::BLEED_LEFT_OUT); + let row = json!([{"format": "pdf", "standard": "pdfX4"}]); + assert!(warned(&export(&mut s, json!({"formats": row})))); + assert!(!warned(&export(&mut s, json!({"includeBleed": true, "formats": row})))); + // A selection is written on a page of its own bounds, which Include Bleed doesn't grow. + s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); + assert!(warned(&export(&mut s, json!({"includeBleed": true, "selection": "one", "formats": row})))); +} + #[test] fn png_8_rows_write_indexed_colour() { let mut s = session(1); diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index ccbe9c049..ba99c4beb 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -49,6 +49,8 @@ mod presets; mod report; pub(crate) use analysis::{outlined_text, see_through_images}; +#[cfg(test)] +pub(crate) use apply::with_every_region; pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images}; pub use commands::specs; pub use options::FlattenOptions; diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index e4113b47b..2b0b1d072 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -150,9 +150,15 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg } // An unclipped full raster needs no regions. let whole = o.balance <= 0.0 && !o.clip_complex_regions; - let regions = if whole { vec![] } else { po::regions(&b.shapes) }; + // Nor does one that splits into more regions than the balance allows: the build stops at the + // first region past the limit. + let (regions, over) = match o.region_limit().filter(|_| !every_region()) { + _ if whole => (vec![], false), + Some(limit) => po::regions_within(&b.shapes, limit).map_or((vec![], true), |r| (r, false)), + None => (po::regions(&b.shapes), false), + }; // An arrangement that failed (degenerate geometry) leaves only rasterizing. - let all = regions.is_empty() || o.rasterize_all(regions.len()); + let all = over || regions.is_empty() || o.rasterize_all(regions.len()); let mut vector: Vec<(usize, RegionFill)> = vec![]; let mut complex: Vec = vec![]; let mut gradients_only = !all; @@ -195,6 +201,32 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg Some((vector, area)) } +#[cfg(test)] +thread_local! { + /// Groups flattened on this thread build all of their regions ([`with_every_region`]). + static EVERY_REGION: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Whether groups build all of their regions, past the balance's limit too. +#[cfg(test)] +fn every_region() -> bool { + EVERY_REGION.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn every_region() -> bool { + false +} + +/// `f` with every group it flattens building all of its regions, past the balance's limit too. +#[cfg(test)] +pub(crate) fn with_every_region(f: impl FnOnce() -> T) -> T { + EVERY_REGION.with(|e| e.set(true)); + let out = f(); + EVERY_REGION.with(|e| e.set(false)); + out +} + /// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where /// nothing paints it. fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option { diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs index 52a900289..a5881c76e 100644 --- a/crates/engine/src/cmd/flatten/options.rs +++ b/crates/engine/src/cmd/flatten/options.rs @@ -169,9 +169,16 @@ impl FlattenOptions { } /// Whether a group that splits into `regions` atomic regions is rasterized whole. - pub(super) fn rasterize_all(&self, regions: usize) -> bool { + pub(crate) fn rasterize_all(&self, regions: usize) -> bool { self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0)) } + + /// The most atomic regions a group can split into without being rasterized whole, when a group + /// with more needs none of its regions: `None` at a balance of 0 or 100, and with Clip Complex + /// Regions (a group rasterized whole is clipped to all of its regions). + pub(crate) fn region_limit(&self) -> Option { + (!self.clip_complex_regions && self.balance > 0.0 && self.balance < 100.0).then(|| 10f64.powf(self.balance / 20.0).floor() as usize) + } } pub(super) fn flatten(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index e88e2810c..7a47457c2 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -42,7 +42,7 @@ use data::{fmt_value, parse_csv, to_csv}; use generate::generate; #[cfg(test)] use series::default_series_color; -pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, paint_targets}; +pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint}; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/graph/axis.rs b/crates/engine/src/cmd/graph/axis.rs index 52e49dbee..47858d395 100644 --- a/crates/engine/src/cmd/graph/axis.rs +++ b/crates/engine/src/cmd/graph/axis.rs @@ -1,4 +1,4 @@ -use super::Value; +use super::{GraphSpec, Value}; pub(super) const LABEL_SIZE: f64 = 9.0; @@ -32,6 +32,11 @@ pub(super) fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) { ((lo / step).floor() * step, (hi / step).ceil() * step, step) } +/// The value axis of a radar graph: from 0 to the largest value of any series. +pub(super) fn radar_axis(g: &GraphSpec) -> (f64, f64, f64) { + value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)) +} + /// A value axis: with both `min` and `max` set (Graph Type › Tick Values › Override Calculated Values), exactly that /// range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using whichever bound /// was given. diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 6db9a0fe4..d1a5ddde0 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -1,8 +1,8 @@ -use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, tick_values, value_axis}; +use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, radar_axis, tick_values, value_axis}; use super::columns::{Allowance, ColumnArt, column_art, column_designs}; use super::data::fmt_value; use super::labels::{ - FILLED_LINE_BUDGET, Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, label_size, label_width, polyline, recolour_text, runs, shadow_of, + Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; use super::series::{series_marks, with_series_opacity}; @@ -264,7 +264,7 @@ impl<'a> Plot<'a> { right_edge: g.rect.x1, wedge_labels: vec![], on_top: vec![], - filled: g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET, + filled: filled_lines_on(g, nser), bars_reach: None, shadowed: vec![], series: vec![vec![]; nser], @@ -422,7 +422,7 @@ impl<'a> Plot<'a> { let (g, r, ncat, cs) = (self.g, self.r, self.ncat, self.cs); let centre = r.center(); let rad = (r.width().min(r.height()) / 2.0).max(1.0); - let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)); + let (lo, hi, step) = radar_axis(g); let at = |c: usize, v: f64| { let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64; centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0)) diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index 664356499..fdd640a95 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -90,9 +90,10 @@ impl Gen<'_> { }; // A run the outline can't be made for (its points all in one place) stays a thin line. if !band.is_empty() { - // The band takes the series' colour, set as a fill or, on a thin line, as its stroke; a thin black outline. + // The band takes the series' color, set as a fill or, on a thin line, as a stroke with paint; a thin black outline. let slot = g.series_paints.get(s); - let fill = slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone())).unwrap_or_else(|| default_series_paint(s)); + let fill = + slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone().filter(|st| !st.is_none()))).unwrap_or_else(|| default_series_paint(s)); return self.path(band, fill, Paint::solid(Color::BLACK), 0.25); } let (fill, stroke, w) = series_marks(g, s, false, default_series_paint(s), 1.0); @@ -140,7 +141,12 @@ pub(super) const SHADOW_OPACITY: f32 = 0.35; /// Draw Filled Lines: how wide a filled line is. const FILLED_LINE_WIDTH: f64 = 3.0; -pub(super) const FILLED_LINE_BUDGET: usize = 20_000; +const FILLED_LINE_BUDGET: usize = 20_000; + +/// Does graph `g`, with `series` series, draw Filled Lines (up to FILLED_LINE_BUDGET data points)? +pub(super) fn filled_lines_on(g: &GraphSpec, series: usize) -> bool { + g.filled_lines && g.rows.len().saturating_mul(series) <= FILLED_LINE_BUDGET +} /// Most marks a graph draws shadows for (each is a copy). pub(super) const SHADOW_BUDGET: usize = 200_000; diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 1b864ba92..8ab2573ff 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,4 +1,5 @@ -use super::labels::{Role, label_style}; +use super::axis::radar_axis; +use super::labels::{Role, filled_lines_on, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; // ---------- generation ---------- @@ -121,11 +122,82 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo let node = doc.node(*id)?; let paint = node.appearance.paint_at(None, fill)?.clone(); let width = (!fill).then(|| node.appearance.stroke_width()); - Some((r.graph, r.index, paint, width)) + Some((r.graph, r.index, fill, paint, width)) }) .collect(); + store_series_paints(doc, captures) +} + +/// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint). +/// With no such row: no paint. +pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) { + match node.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()) { + Some(vectorcraft_doc::AppearanceItem::Stroke(st)) if !st.paint.is_none() => (st.paint.clone(), st.width), + Some(it) => (it.paint().clone(), 0.0), + None => (Paint::None, 0.0), + } +} + +/// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series +/// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series' +/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its +/// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series' +/// captured fill or stroke changed. +pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { + if edited.is_empty() { + return false; + } + let members = series_members(doc); + if members.is_empty() { + return false; + } + // Each series is checked once: the check reads the graph's whole grid. + let mut bands: HashMap<(NodeId, usize), bool> = HashMap::new(); + let mut captures = Vec::new(); + for (id, fill) in edited { + // Only the paths the graph draws again, not the copy of a graph design. + let Some(r) = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group)) else { continue }; + let Some(node) = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. })) else { continue }; + let drawn = || doc.node(r.graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(|g| series_draws_bands(g, r.index)); + if !*fill && *bands.entry((r.graph, r.index)).or_insert_with(drawn) { + continue; + } + let (paint, width) = shown_paint(node, *fill); + captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width))); + } + store_series_paints(doc, captures) +} + +/// Does series `i` draw any Filled Lines band? A line needs two neighboring values, a scatter series two neighboring points +/// in different places (its index counts pairs of columns), and a radar series two categories with a value plotted away +/// from the center. +fn series_draws_bands(g: &GraphSpec, i: usize) -> bool { + let series = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + if !g.connect_points || !filled_lines_on(g, series) { + return false; + } + let cells = g.cells(); + let cell = |row: &[Option], s: usize| row.get(s).copied().flatten(); + match g.series_kind(i) { + GraphKind::Line => cells.windows(2).any(|w| w.iter().all(|row| cell(row, i).is_some())), + GraphKind::Radar => { + let (lo, hi, _) = radar_axis(g); + cells.len() > 1 && cells.iter().any(|row| (cell(row, i).unwrap_or(0.0) - lo) / (hi - lo) > 0.0) + } + GraphKind::Scatter => { + let s = i.saturating_mul(2); + let point = |row: &[Option]| Some((cell(row, s)?, cell(row, s.saturating_add(1))?)); + cells.windows(2).any(|w| matches!(w, [a, b] if point(a).is_some() && point(b).is_some() && point(a) != point(b))) + } + _ => false, + } +} + +/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a +/// series' captured fill or stroke changed. +fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option)>) -> bool { let mut changed = false; - for (graph, index, paint, width) in captures { + for (graph, index, fill, paint, width) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let was = spec.series_paints.clone(); // `index` is below the graph's series count (`series_members`). @@ -144,7 +216,7 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo changed } -/// Copy the opacity of the topmost fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: +/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: /// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind /// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity /// changed. @@ -162,7 +234,8 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool // Only the paths the graph draws again, not the copy of a graph design. let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; - let opacity = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill).map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); + let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible()); + let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity))) }) .collect(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 2be4bbf48..8b059c111 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -503,6 +503,819 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() { assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6)); } +/// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight. +fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, index).children().unwrap().to_vec(); + marks.iter().map(|m| (m.appearance.fill_paint(), m.appearance.stroke_paint(), m.appearance.stroke_width())).collect() +} + +/// Set the top fill of series 0's first mark to the color `hex` with `appearance.setItem`, and return that paint. +fn hex_paint(s: &mut Session, id: NodeId, hex: &str) -> Paint { + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": hex})).unwrap(); + s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint() +} + +/// A color or weight set with `appearance.setItem` on the topmost fill or stroke of one series mark is kept by the +/// graph: every mark of that series has it after the graph is drawn again, and undo takes it back with its edit. A row +/// under the topmost one is not kept, and a change to one kind of row keeps nothing for the other. +#[test] +fn an_appearance_item_paint_on_a_series_mark_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let blue = hex_paint(&mut s, id, "#0000ff"); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + let row = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap(); + let red = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke, kept.stroke_width), (Some(red.clone()), Some(3.0))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| *m == (blue.clone(), red.clone(), 3.0)), "{:?}", marks_of(&s, id, 0)); + assert!(marks_of(&s, id, 1).iter().all(|m| m.0 != blue)); + // Undo goes back through the data edit, then takes the stroke paint away with its edit. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(blue.clone()), None, None)); + // The graph does not keep a color set on a fill under the topmost one. + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap(); + let (lower, _) = fill_rows(&s, bar); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "color": "#00ff00"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue)); +} + +/// A weight set on a stroke row, with no color, is kept by the graph with the row's paint. +#[test] +fn an_appearance_item_weight_on_a_series_mark_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + let row = bar.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "color": "#ff0000"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [bar.id.0], "index": row, "weight": 4})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(4.0)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.2 == 4.0), "{:?}", marks_of(&s, id, 0)); +} + +/// A column graph with series 0 kept blue at 40% opacity: the graph, the paint, the first marks of series 0 and 1. +fn blue_source() -> (Session, NodeId, Paint, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let blue = hex_paint(&mut s, id, "#0000ff"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id); + s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap(); + (s, id, blue, from, to) +} + +/// After the graph is drawn again, every mark of series 1 is `blue` at 40%; undo goes back through the data edit and the +/// command before it, which restores the paints the graph kept in `was`. +fn series_1_is_blue_at_40_then_undo(s: &mut Session, id: NodeId, blue: &Paint, was: &[vectorcraft_doc::SeriesPaint]) { + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + for m in series(&n, 1).children().unwrap() { + let f = m.appearance.fill().unwrap(); + assert_eq!((f.paint.clone(), f.opacity), (blue.clone(), 0.4)); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was); +} + +/// The topmost fill (`true`) or stroke (`false`) row of `mark`. +fn top_row(s: &Session, mark: NodeId, fill: bool) -> usize { + s.doc().unwrap().doc.node(mark).unwrap().appearance.items.iter().rposition(|it| it.is_fill() == fill).unwrap() +} + +/// A new column graph, the fill of series 0's marks and those marks' ids. +fn column_marks() -> (Session, NodeId, Paint, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + (s, id, marks[0].appearance.fill_paint(), marks.iter().map(|m| m.id).collect()) +} + +/// An appearance moved onto a mark of another series makes the graph keep its paint with its opacity for that series, +/// and leaves the source series without paint. +#[test] +fn moving_an_appearance_onto_a_series_mark_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0})).unwrap(); + let paints = spec(&s, id).series_paints; + assert_eq!((paints[0].fill.clone(), paints[1].fill.clone()), (Some(Paint::None), Some(blue.clone()))); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// An appearance copied onto a mark of another series makes the graph keep its paint with its opacity for that series. +#[test] +fn copying_an_appearance_onto_a_series_mark_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone())); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// An Eyedropper pick onto a mark of another series makes the graph keep its paint with its opacity for that series. +#[test] +fn an_eyedropper_pick_onto_a_series_mark_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone())); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// A reversed Eyedropper (Alt-click) copies onto the clicked mark, and the graph keeps the paint for the clicked series. +#[test] +fn a_reversed_eyedropper_pick_keeps_its_paint_on_the_clicked_series() { + let (mut s, id, blue, from, to) = blue_source(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": to.0, "ids": [from.0], "reverse": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue.clone())); + series_1_is_blue_at_40_then_undo(&mut s, id, &blue, &was); +} + +/// A reversed Eyedropper with nothing selected copies what it picked up last onto the clicked mark. +#[test] +fn a_reversed_eyedropper_pick_of_the_last_sample_keeps_its_paint() { + let (mut s, id, blue, from, to) = blue_source(); + s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": []})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": to.0, "reverse": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue)); +} + +/// Clear Appearance on a series mark leaves it with no fill and no stroke, and the series stays that way when the graph +/// is drawn again. Undo brings the kept paint back. +#[test] +fn clearing_a_series_marks_appearance_updates_its_kept_paint() { + let (mut s, id, _, marks) = column_marks(); + let row = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": row, "color": "#ff0000", "weight": 2})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("appearance.clear", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (Some(Paint::None), Some(Paint::None), Some(0.0))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| *m == (Paint::None, Paint::None, 0.0)), "{:?}", marks_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// A mark with `blue` under a second fill: the graph, the paint, the mark and its (lower, upper) fill rows, with the +/// upper row green and kept. +fn two_fills() -> (Session, NodeId, Paint, NodeId, usize, usize) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let blue = hex_paint(&mut s, id, "#0000ff"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap(); + let (lower, upper) = fill_rows(&s, bar); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "color": "#00ff00"})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, Some(blue.clone())); + (s, id, blue, bar, lower, upper) +} + +/// `cmd` makes the graph keep `blue`, and undo brings back the paints it kept before. +fn top_row_command_keeps_blue(s: &mut Session, id: NodeId, blue: &Paint, cmd: &str, p: &serde_json::Value) { + let was = spec(s, id).series_paints; + s.execute(cmd, p).unwrap(); + assert_eq!(spec(s, id).series_paints[0].fill.as_ref(), Some(blue), "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(s, id).series_paints, was, "{cmd}"); +} + +/// Reduce to Basic Appearance with the lower fill as the active row leaves that fill, and the graph keeps its paint. +#[test] +fn reducing_a_series_marks_appearance_keeps_the_row_it_leaves() { + let (mut s, id, blue, bar, lower, _) = two_fills(); + s.execute("select.set", &json!({"ids": [bar.0]})).unwrap(); + s.execute("appearance.setActiveItem", &json!({"index": lower})).unwrap(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.reduceToBasic", &json!({})); +} + +/// Removing a mark's top row makes the graph keep the paint now on top. +#[test] +fn removing_a_series_marks_top_row_keeps_the_paint_now_on_top() { + let (mut s, id, blue, bar, _, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.removeItem", &json!({"ids": [bar.0], "index": upper})); +} + +/// Moving a lower row above the others makes the graph keep its paint. +#[test] +fn moving_a_row_to_a_series_marks_top_keeps_its_paint() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "from": lower, "to": upper})); +} + +/// Moving a block of rows above the others makes the graph keep the paint now on top. +#[test] +fn moving_a_block_of_rows_to_a_series_marks_top_keeps_its_paint() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.moveItem", &json!({"ids": [bar.0], "indices": [lower], "to": upper})); +} + +/// A copy of a lower row placed on top makes the graph keep its paint. +#[test] +fn a_copy_placed_on_a_series_marks_top_keeps_its_paint() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + top_row_command_keeps_blue(&mut s, id, &blue, "appearance.duplicateItem", &json!({"ids": [bar.0], "index": lower, "to": upper + 1})); +} + +/// The mark's only stroke row removed: the series draws without a stroke; a new row comes back as its paint. +#[test] +fn removing_and_adding_a_series_marks_only_stroke_updates_its_kept_paint() { + let (mut s, id, _, marks) = column_marks(); + let bar = marks[0]; + let row = top_row(&s, bar, false); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": row})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.stroke, kept.stroke_width), (None, Some(Paint::None), Some(0.0))); + let was = spec(&s, id).series_paints; + s.execute("appearance.addStroke", &json!({"ids": [bar.0]})).unwrap(); + let stroke = s.doc().unwrap().doc.node(bar).unwrap().appearance.stroke_paint(); + assert!(!stroke.is_none()); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(stroke)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// Hiding the top row makes the graph keep the visible row under it, and its opacity; hiding the only one keeps no paint; +/// Show All Hidden Attributes brings the top row back. A hidden row on top of a moved appearance is not kept. +#[test] +fn a_hidden_top_row_is_not_what_the_graph_keeps() { + let (mut s, id, blue, bar, lower, upper) = two_fills(); + let green = s.doc().unwrap().doc.node(bar).unwrap().appearance.fill_paint(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": lower, "opacity": 50})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None, "the row under the top one"); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == blue), "{:?}", marks_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill, kept.fill_opacity), (Some(green), None)); + + // A transfer of [blue, hidden green] onto another series keeps the blue the source shows. + let (mut s, id, blue, bar, _, upper) = two_fills(); + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.transfer", &json!({"source": bar.0, "target": to.0, "copy": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(blue)); + + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "visible": false})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None)); +} + +/// Hiding a top row and Show All Hidden Attributes each undo in one step, with what the graph stores. +#[test] +fn hiding_and_showing_a_series_marks_top_row_undo_with_the_stored_paint() { + let (mut s, id, _, bar, _, upper) = two_fills(); + let was = spec(&s, id).series_paints; + s.execute("appearance.setItem", &json!({"ids": [bar.0], "index": upper, "visible": false})).unwrap(); + let hidden = spec(&s, id).series_paints; + assert_ne!(hidden, was); + s.execute("appearance.showAllHidden", &json!({"ids": [bar.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, hidden); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// An opaque Eyedropper pick onto a mark replaces the opacity another mark of the series stored, even though the mark +/// itself stays opaque: the series is drawn with the picked color at 100%. +#[test] +fn an_opaque_pick_replaces_the_opacity_the_series_stores() { + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "color": "#0000ff"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + + // A pick of the opacity alone replaces it too, and a pick that leaves opacity out keeps it. + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let colors_only = json!({"appearance": {"fill": {"color": true, "transparency": false}, "stroke": {"color": false}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": colors_only})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); + let opacity_only = json!({"appearance": {"fill": {"color": false, "transparency": true}, "stroke": {"color": false}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": opacity_only})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); +} + +/// An appearance moved between two marks of one series leaves the series with the target's paint, not the source's +/// empty one, and the series still shows when the graph is drawn again. +#[test] +fn moving_an_appearance_within_a_series_keeps_the_targets_paint() { + let (mut s, id, shown, marks) = column_marks(); + s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": marks[1].0})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown.clone())); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == shown), "{:?}", marks_of(&s, id, 0)); +} + +/// A row shown again with the color the mark already shows replaces the paint another mark of the series stored. +#[test] +fn showing_a_row_with_the_color_a_mark_shows_replaces_the_kept_paint() { + let (mut s, id, shown, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + let other = top_row(&s, marks[1], true); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": other, "color": "#ff0000"})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone())); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": true, "color": "#000000"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint())); +} + +/// A color or weight set again to what a mark already shows and an Eyedropper pick that leaves the mark as it was replace +/// the paint another mark of the series stored, in an undo step of their own. Clear Appearance on a clear mark leaves the +/// stored paint and the undo history as they are. +#[test] +fn writing_the_paint_a_series_mark_already_shows_replaces_the_kept_paint() { + let (mut s, id, shown, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, Some(shown.clone())); + let was = spec(&s, id).series_paints; + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "color": "#000000"})).unwrap(); + let again = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(again)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + for (mark, weight) in [(marks[1], 2), (marks[0], 5), (marks[1], 2)] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": stroke, "color": "#ff0000", "weight": weight})).unwrap(); + } + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0)); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "weight": 5})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 2})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0)); + + let (mut s, id, shown, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": marks[2].0, "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(shown)); + + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + let was = spec(&s, id).series_paints; + let shown = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("edit.undo", &json!({})).unwrap(); + assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), shown, "the undo took back the color, not a clear"); +} + +/// A line graph with a marker: the graph, the first thin line, a marker. +fn line_and_marker() -> (Session, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id; + let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id; + (s, id, line, marker) +} + +/// The first thin line of series 0, and every other path of the series (its markers and legend swatch). +fn line_parts(s: &Session, id: NodeId) -> (vectorcraft_doc::Node, Vec) { + let parts = series_parts(s, id, 0); + let (lines, others): (Vec<_>, Vec<_>) = + parts.into_iter().partition(|m| m.appearance.fill_paint().is_none() && m.appearance.stroke_width() == 1.0); + (lines[0].clone(), others) +} + +/// On a line graph, a stroke set on the thin line goes on the line after a redraw; a fill set on the thin line goes on +/// the markers and the legend swatch, and the line stays without a fill. +#[test] +fn a_line_series_draws_its_kept_stroke_on_the_line_and_its_kept_fill_on_the_markers() { + let (mut s, id, line, _) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint(); + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, true), "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(line).unwrap().appearance.fill_paint(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let (drawn, others) = line_parts(&s, id); + assert_eq!((drawn.appearance.fill_paint(), drawn.appearance.stroke_paint()), (Paint::None, red)); + assert!(others.iter().all(|m| m.appearance.fill_paint() == blue), "{others:?}"); +} + +/// Clear Appearance on a thin line leaves the fill the graph stores for the markers; on a marker that shows no stroke, it +/// leaves the stroke the graph stores for the line. An Eyedropper pick of a stroke-only source onto the line and a copy +/// of one line onto another leave the stored fill too. +#[test] +fn a_row_left_without_paint_leaves_the_paint_the_series_stores() { + let (mut s, id, line, marker) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap(); + let blue = spec(&s, id).series_paints[0].fill.clone().unwrap(); + s.execute("appearance.clear", &json!({"ids": [line.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill.as_ref(), kept.stroke.as_ref()), (Some(&blue), Some(&Paint::None))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let parts = series_parts(&s, id, 0); + assert!(parts.iter().filter(|m| !m.appearance.fill_paint().is_none()).all(|m| m.appearance.fill_paint() == blue)); + assert!(parts.iter().any(|m| m.appearance.fill_paint() == blue), "the markers keep their fill"); + + let (mut s, id, line, marker) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint(); + s.execute("appearance.clear", &json!({"ids": [marker.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()), "the marker showed no stroke"); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red); + + let (mut s, id, line, marker) = line_and_marker(); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, true), "color": "#0000ff"})).unwrap(); + let blue = spec(&s, id).series_paints[0].fill.clone().unwrap(); + let other = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.copyFrom", &json!({"source": other.0, "ids": [line.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue.clone()), "the source's line has no fill"); + s.execute("appearance.transfer", &json!({"source": other.0, "target": line.0, "copy": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(blue)); +} + +/// A line graph with Filled Lines on: the session, the graph and series 0's band. +fn band_graph() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "line", ",a\nQ1,2\nQ2,3\nQ3,2"); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let band = series_parts(&s, id, 0)[0].id; + (s, id, band) +} + +/// The band's fill, outline paint and outline weight after the graph is drawn again. +fn redrawn_band(s: &mut Session, id: NodeId) -> (Paint, Paint, f64) { + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [4]]})).unwrap(); + let band = series_parts(s, id, 0)[0].clone(); + (band.appearance.fill_paint(), band.appearance.stroke_paint(), band.appearance.stroke_width()) +} + +/// A fill set on a Filled Lines band is kept, and the band keeps its thin black outline. +#[test] +fn a_filled_line_band_keeps_a_fill_set_on_it() { + let (mut s, id, band) = band_graph(); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": top_row(&s, band, true), "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint(); + assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); +} + +/// A stroke set on a Filled Lines band is not kept: the band keeps its color and its thin black outline. +#[test] +fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() { + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "color": "#ff0000", "weight": 3})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none())); + assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); +} + +/// An opacity set on a Filled Lines band's stroke row is kept, without a stroke paint, and goes on the band's thin black +/// outline when the graph is drawn again. +#[test] +fn a_stroke_opacity_set_on_a_filled_line_band_goes_on_its_outline() { + let (mut s, id, band) = band_graph(); + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 30})).unwrap(); + let kept = spec(&s, id).series_paints.first().cloned().unwrap_or_default(); + assert_eq!((kept.stroke.is_none(), kept.stroke_opacity), (true, Some(0.3))); + let (_, outline, width) = redrawn_band(&mut s, id); + assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); + let band = series_parts(&s, id, 0)[0].clone(); + assert_eq!(band.appearance.stroke().unwrap().opacity, 0.3); +} + +/// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the +/// band its color: an empty stored stroke is not a color for the band. +#[test] +fn removing_a_filled_line_bands_stroke_leaves_its_color() { + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + s.execute("appearance.removeItem", &json!({"ids": [band.0], "index": top_row(&s, band, false)})).unwrap(); + assert_eq!(redrawn_band(&mut s, id).0, was); + + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); + assert_eq!(redrawn_band(&mut s, id).0, was); +} + +/// With Connect Data Points off a Filled Lines graph draws no bands, so a stroke set on a marker is kept. +#[test] +fn a_stroke_on_a_marker_is_kept_when_filled_lines_draws_no_bands() { + let (mut s, id, _) = band_graph(); + s.execute("graph.setType", &json!({"id": id.0, "connectPoints": false})).unwrap(); + let marker = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "color": "#ff0000"})).unwrap(); + assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none())); +} + +/// A Filled Lines graph of `ty` and `csv` draws no bands, so a stroke set on its first mark is stored and stays on it after +/// the graph is drawn again. +fn stroke_is_kept_without_bands(ty: &str, csv: &str) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, ty, csv); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let mark = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap(); + let red = s.doc().unwrap().doc.node(mark).unwrap().appearance.stroke_paint(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone())); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let drawn = &series_parts(&s, id, 0)[0]; + assert_eq!((drawn.appearance.stroke_paint(), drawn.appearance.stroke_width()), (red, 2.0)); +} + +#[test] +fn a_stroke_is_kept_on_a_one_row_line_series() { + stroke_is_kept_without_bands("line", ",a\nQ1,2"); +} + +#[test] +fn a_stroke_is_kept_on_a_radar_series_of_zeros() { + stroke_is_kept_without_bands("radar", ",a\nQ1,0\nQ2,0\nQ3,0"); +} + +#[test] +fn a_stroke_is_kept_on_a_scatter_series_of_one_repeated_point() { + stroke_is_kept_without_bands("scatter", "a,b\n1,2\n1,2"); +} + +/// A radar series whose values all fall below `axisMin` is plotted at the center and draws no band, so a stroke set on its +/// marks is kept. A radar series with a value above `axisMin` draws a band and stores no stroke. +#[test] +fn a_radar_series_draws_a_band_only_for_values_away_from_the_center() { + for (csv, kept) in [(",a\nQ1,1\nQ2,2\nQ3,3", true), (",a\nQ1,1\nQ2,15\nQ3,3", false)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "radar", csv); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "axisMin": 10, "axisMax": 20})).unwrap(); + let mark = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": "#ff0000", "weight": 2})).unwrap(); + assert_eq!(spec(&s, id).series_paints.first().is_some_and(|p| p.stroke.is_some()), kept, "{csv}"); + } +} + +/// Past the Filled Lines budget the graph draws thin lines, and a stroke set on one is kept. +#[test] +fn a_stroke_on_a_thin_line_past_the_filled_lines_budget_is_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let rows: Vec> = (0..8_000).map(|i| vec![(i % 7) as f64, 1.0, 2.0]).collect(); + let id = NodeId( + s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true, "markPoints": false})).unwrap(); + let line = series_parts(&s, id, 0)[0].id; + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + assert!(spec(&s, id).series_paints[0].stroke.as_ref().is_some_and(|p| !p.is_none())); +} + +/// A weight of 0 set again on a painted stroke replaces the weight another mark of the series left on the graph. +#[test] +fn a_zero_weight_on_a_painted_stroke_replaces_the_kept_weight() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 0})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0)); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "weight": 0})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(0.0)); +} + +/// An Eyedropper `append` of a source with no stroke leaves the stroke the series keeps. +#[test] +fn appending_a_fill_only_appearance_leaves_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.removeItem", &json!({"ids": [source.0], "index": top_row(&s, source, false)})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0)); + // A pick of the weight alone from that source leaves the mark and what the series keeps as they were. + let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(5.0)); +} + +/// An Eyedropper `append` of a source with only a hidden fill leaves the fill the series keeps. +#[test] +fn appending_a_hidden_fill_leaves_the_kept_fill() { + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + let kept = spec(&s, id).series_paints[0].fill.clone(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": top_row(&s, source, true), "visible": false})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, kept); +} + +/// An Eyedropper pick of the weight alone reads a hidden stroke too, and that weight replaces the one the series keeps. +#[test] +fn picking_the_weight_of_a_hidden_stroke_replaces_the_kept_weight() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#ff0000", "weight": 5})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let row = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 2, "visible": false})).unwrap(); + let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": false, "weight": true}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_width, Some(2.0)); +} + +/// An Eyedropper pick of some attributes edits the mark's topmost stroke. When that row is hidden, the series keeps what +/// it stores instead of taking the visible row under it. +#[test] +fn a_partial_pick_onto_a_hidden_top_row_leaves_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap(); + let hidden = top_row(&s, marks[1], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": hidden, "visible": false})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let row = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#00ff00", "weight": 3})).unwrap(); + let pick = json!({"appearance": {"fill": {"color": false}, "stroke": {"color": true, "weight": true}}}); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "pickUp": pick})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept); +} + +/// An Eyedropper `append` writes a visible row under the source's hidden top row, and the series stores that row's paint +/// even when the mark already shows the same paint. +#[test] +fn appending_a_visible_row_under_a_hidden_one_replaces_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap(); + let source = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + let row = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": row, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [source.0]})).unwrap(); + let hidden = top_row(&s, source, false); + s.execute("appearance.setItem", &json!({"ids": [source.0], "index": hidden, "visible": false})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": source.0, "ids": [marks[1].0], "append": true})).unwrap(); + let kept = &spec(&s, id).series_paints[0]; + assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))); + assert_eq!(kept.stroke_width, Some(2.0)); +} + +/// A stroke added on top of a mark replaces the stroke another mark of the series left on the graph, even when the new +/// row shows the paint the mark already showed. +#[test] +fn adding_a_stroke_replaces_the_kept_stroke_with_the_marks_own() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap(); + s.execute("appearance.addStroke", &json!({"ids": [marks[1].0]})).unwrap(); + let kept = &spec(&s, id).series_paints[0]; + assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))); + assert_eq!(kept.stroke_width, Some(2.0)); +} + +/// A fill added right above a mark's active top fill replaces the fill another mark of the series left on the graph. +#[test] +fn adding_a_fill_above_the_active_top_fill_replaces_the_kept_fill() { + let (mut s, id, was, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#0000ff"})).unwrap(); + s.execute("select.set", &json!({"ids": [marks[1].0]})).unwrap(); + s.execute("appearance.setActiveItem", &json!({"index": fill})).unwrap(); + s.execute("appearance.addFill", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(was)); +} + +/// A copy of a mark's top stroke replaces the stroke another mark of the series left on the graph. +#[test] +fn duplicating_a_marks_top_stroke_replaces_the_kept_stroke() { + let (mut s, id, _, marks) = column_marks(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap(); + for to in [false, true] { + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#0000ff", "weight": 5})).unwrap(); + let top = top_row(&s, marks[1], false); + let p = if to { json!({"ids": [marks[1].0], "index": top, "to": top + 1}) } else { json!({"ids": [marks[1].0], "index": top}) }; + s.execute("appearance.duplicateItem", &p).unwrap(); + let kept = &spec(&s, id).series_paints[0]; + assert_eq!(kept.stroke, Some(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))), "{p}"); + assert_eq!(kept.stroke_width, Some(2.0), "{p}"); + } +} + +/// A weight set on a stroke without paint (a line graph's marker) leaves the paint the series keeps for its line. +#[test] +fn a_weight_on_a_stroke_without_paint_leaves_the_kept_stroke() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + let line = marks.iter().find(|m| m.appearance.stroke_width() > 0.0).unwrap().id; + let marker = marks.iter().find(|m| m.appearance.stroke_width() == 0.0 && m.appearance.stroke().is_some()).unwrap().id; + s.execute("appearance.setItem", &json!({"ids": [line.0], "index": top_row(&s, line, false), "color": "#ff0000"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.stroke.as_ref().is_some_and(|p| !p.is_none())); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": top_row(&s, marker, false), "weight": 4})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept); +} + +/// The graph does not keep a paint set on a row of a graph design's copy inside a series. A stroke edit on a line keeps +/// no fill, and a blend mode alone leaves what the graph keeps as it was. +#[test] +fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + save_design(&mut s, "Square"); + s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + assert!(matches!(copy.kind, NodeKind::Group { .. })); + s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none())); + // A path inside the design's copy is a member of the series too, and its paint is not kept either. + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + let mut paths = vec![]; + copy.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) { + paths.push(n.id); + } + }); + let art = paths[0]; + s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none())); + + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let line = series(&n, 0).children().unwrap().iter().find(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).unwrap().clone(); + let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap(); + assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill"); + + // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it. + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); + let kept = spec(&s, id); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap(); + assert_eq!(spec(&s, id), kept); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 11bf22110..d4adaf2fd 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -955,7 +955,7 @@ fn any_empty_artboards(d: &Document) -> bool { fn has_empty_artboards(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; - if any_empty_artboards(&st.doc) { Ok(()) } else { Err("no empty artboards to delete".into()) } + if st.any_empty_artboards(any_empty_artboards) { Ok(()) } else { Err("no empty artboards to delete".into()) } } fn artboard_delete_empty(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index cf5887074..0a81a947e 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -10,7 +10,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::layer::{NewLayers, row_roots, rows_param}; -use super::{CommandSpec, Result, Session, bad, checked_id, cmd, has_doc, id_param, ok, switch_or}; +use super::{CommandSpec, Result, Session, bad, checked_id, cmd, has_doc, object_param, ok, switch_or}; use crate::EngineError; pub fn specs() -> Vec { @@ -336,7 +336,7 @@ fn release(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.releaseToLayers"; let build = switch_or(p, "build", false, C)?; let st = s.doc()?; - let id = match id_param(p, "id") { + let id = match object_param(p, "id", C)? { Some(id) => id, None => match st.highlighted_rows().as_slice() { [id] => *id, @@ -434,8 +434,9 @@ fn reverse(s: &mut Session, p: &Value) -> Result { } fn locate(s: &mut Session, p: &Value) -> Result { + let id = object_param(p, "id", "layer.locate")?; let st = s.doc_mut()?; - let id = id_param(p, "id").or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?; + let id = id.or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?; let ancestry = st.doc.ancestry(id).ok_or(EngineError::NoNode(id))?; st.layer_rows = vec![id]; st.revision += 1; diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index bcda11c2b..89492ad01 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -24,14 +24,15 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Component, Path}; +use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions}; -use vectorcraft_geom::{Affine, Rect, shapes}; +use vectorcraft_geom::{Affine, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, id_param, ids_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param}; pub fn specs() -> Vec { vec![ @@ -49,7 +50,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. One undo step → {updated: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids]}", has_doc, update ), @@ -67,7 +68,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", has_doc, relink ), @@ -483,23 +484,183 @@ fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result { Ok(Read::Document(f.link(&bytes, &g.link), src)) } -/// Objects `ids` show what was read (see [`install`], [`install_document`]). -fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option) -> Result<()> { +/// Objects `ids` show what was read (see [`install`], [`install_document`]), and with `also`, the +/// objects outside the layer tree that show group `also`'s content. +fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option, also: Option<&Group>) -> Result<()> { + let also = also.map(|g| (g.link.path.as_str(), g.key.as_str())); match read { - Read::Image(r) => install(d, ids, r, force), - Read::Document(link, src) => install_document(d, ids, &link, &src, force), + Read::Image(r) => install(d, ids, r, force, also), + Read::Document(link, src) => install_document(d, ids, &link, &src, force, also), + } +} + +/// Is `n` a linked image (or, with `document`, a placed document) of the file at `path` showing +/// content `key`? +fn shows(n: &Node, (path, key): (&str, &str), document: bool) -> bool { + match &n.kind { + NodeKind::Image(im) => !document && im.key == key && im.link.as_ref().is_some_and(|l| l.path == path), + NodeKind::PlacedDocument(p) => document && p.key == key && p.link.path == path, + _ => false, + } +} + +/// The clipping path of `clip`: its room as a rectangle in its frame. +fn clip_shape((frame, room): Clip) -> PathData { + shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame) +} + +/// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and +/// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for. +struct Nested<'a> { + pick: &'a dyn Fn(&Node) -> bool, + /// The object an object becomes, and the clip it takes ([`placed_xf`]). + make: &'a mut dyn FnMut(&Node) -> (Node, Option), + /// The next node id for a clip group (taken from the document after the walk). + next: u64, +} + +impl Nested<'_> { + /// Every root of `d`: the layers (whose own objects are left alone), then the art of symbols, + /// graph designs and patterns. + fn run(mut self, d: &mut Document) { + let first = d.peek_next_id(); + self.next = first; + for l in d.layers.iter_mut() { + self.update(l, false); + } + for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) { + self.root(root); + } + for root in d.patterns.iter_mut().flat_map(|p| &mut p.art) { + self.root(root); + } + for _ in first..self.next { + d.alloc_id(); + } + } + + /// The art at a root outside the layer tree (a symbol's, graph design's or pattern's art, or + /// mask art). + fn root(&mut self, n: &mut Arc) { + if !(self.pick)(n) { + return self.update(n, true); + } + let (new, clip) = (self.make)(n); + let mut new = Arc::new(new); + self.update(&mut new, true); + *n = match clip { + Some(clip) => Arc::new(self.clip_group(new, clip)), + None => new, + }; + } + + /// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays + /// the same node when none is there, and only the nodes on the way to one are copied. + fn update(&mut self, n: &mut Arc, nested: bool) { + let mask = n.extras().mask.as_ref().and_then(|m| { + let mut art = Arc::clone(&m.art); + self.root(&mut art); + (!Arc::ptr_eq(&art, &m.art)).then_some(art) + }); + let mut slots: Vec<(usize, Arc)> = vec![]; + if let Some(children) = n.children() { + // A clip group holding just the object: its clipping path changes. + let clip_path = match (&n.kind, &children[..]) { + (NodeKind::Group { clip: true, .. }, [c, _]) if matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c), + _ => None, + }; + for (i, c) in children.iter().enumerate() { + if !(nested && (self.pick)(c)) { + let mut new = Arc::clone(c); + self.update(&mut new, nested); + if !Arc::ptr_eq(&new, c) { + slots.push((i, new)); + } + continue; + } + let (new, clip) = (self.make)(c); + let mut new = Arc::new(new); + self.update(&mut new, true); + match (clip, clip_path) { + (None, _) => slots.push((i, new)), + (Some(clip), Some(path)) if i == 1 => { + let mut path = Node::clone(path); + if let NodeKind::Path { path: p, .. } = &mut path.kind { + *p = clip_shape(clip); + } + slots.extend([(0, Arc::new(path)), (1, new)]); + } + (Some(clip), _) => { + let group = self.clip_group(new, clip); + slots.push((i, Arc::new(group))); + } + } + } + } + if mask.is_none() && slots.is_empty() { + return; + } + let n = Arc::make_mut(n); + if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) { + m.art = art; + } + if let Some(children) = n.children_mut() { + for (i, c) in slots { + if let Some(slot) = children.get_mut(i) { + *slot = c; + } + } + } + } + + /// A clip group of `object` clipped to `clip`, as [`clip_image`] makes one in the layers. + fn clip_group(&mut self, object: Arc, clip: Clip) -> Node { + let (path_id, group_id) = (NodeId(self.next), NodeId(self.next + 1)); + self.next += 2; + let none = vectorcraft_color::Paint::None; + let mut path = Node::path(path_id, clip_shape(clip), Appearance::basic(none.clone(), none, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + Node::new(group_id, NodeKind::Group { children: vec![Arc::new(path), object], clip: true }) } } /// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its -/// Placement Options say (`force`: as that Preserve choice says, whatever they say). -fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, force: Option) -> Result<()> { +/// Placement Options say (`force`: as that Preserve choice says, whatever they say); with `also` +/// (a file's path and the content shown), so do the placed documents outside the layer tree that +/// show that content. +fn install_document( + d: &mut Document, + ids: &[NodeId], + link: &LinkInfo, + src: &super::place::document::Source, + force: Option, + also: Option<(&str, &str)>, +) -> Result<()> { let mut clips = vec![]; let (w, h) = (src.frame.width(), src.frame.height()); + let placed = |p: &PlacedDocument| { + let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement }; + placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o) + }; + if let Some(also) = also { + let pick = |n: &Node| shows(n, also, true); + let mut make = |n: &Node| { + let mut n = n.clone(); + let mut clip = None; + if let NodeKind::PlacedDocument(p) = &mut n.kind { + let (xf, c) = placed(p); + (p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone()); + clip = c; + } + (n, clip) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } for id in ids { let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue }; - let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement }; - let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o); + let (xf, clip) = placed(p); if let Some(n) = d.node_mut(*id) && let NodeKind::PlacedDocument(p) = &mut n.kind { @@ -565,8 +726,8 @@ fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), n /// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just /// it changes, else the image goes into a new clip group. -fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> { - let path = shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame); +fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> { + let path = clip_shape(clip); let clip_path = d.parent_of(id).and_then(|p| match &d.node(p)?.kind { NodeKind::Group { children, clip: true } => match &children[..] { [c, im] if im.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c.id), @@ -602,14 +763,33 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_ let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?; let bytes = read_file(&path)?; let read = (link_info(&path, &bytes), linked_image(&bytes)?); - install(d, &[id], read, Some(Preserve::Bounds)) + install(d, &[id], read, Some(Preserve::Bounds), None) } /// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement -/// Options say (`force`: as that Preserve choice says, whatever they say). -fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option) -> Result<()> { +/// Options say (`force`: as that Preserve choice says, whatever they say); with `also` (a file's +/// path and the content shown), so do the images outside the layer tree that show that content. +fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option, also: Option<(&str, &str)>) -> Result<()> { let new_pt = super::place::pt_per_px(img.ppi); let mut clips = vec![]; + if let Some(also) = also { + // Every image picked shows content `also.1`, so one resolution serves them all. + let old_pt = file_pt(d, also.1); + let pick = |n: &Node| shows(n, also, false); + let mut make = |n: &Node| { + let mut n = n.clone(); + let mut clip = None; + if let NodeKind::Image(im) = &mut n.kind { + let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; + let size = |w: u32, h: u32| (w as f64, h as f64); + let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o); + (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); + clip = c; + } + (n, clip) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } for id in ids { let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue }; let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; @@ -678,6 +858,9 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p // of other keys linking to the same file share them, rather than each reading, decoding and // keeping a copy of its own. let mut read_images: BTreeMap = BTreeMap::new(); + // (the path as it was, the content shown, a placed document?, the link now) of each file found + // away from its path or read again. + let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; for g in groups(d, None) { let row = rows(std::iter::once(&g), doc_path); let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); @@ -685,7 +868,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p match probe(&g, doc_path) { (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { - match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) { + let force = unread.then_some(Preserve::Bounds); + match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) { Ok(()) if unread => {} Ok(()) => out.updated.extend(row), Err(_) => out.missing.extend(row), @@ -697,7 +881,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None }; match read { Some(Ok(read)) => { - if install_read(d, &g.ids, read, Some(Preserve::Bounds)).is_err() { + if install_read(d, &g.ids, read, Some(Preserve::Bounds), Some(&g)).is_err() { out.missing.extend(row); } } @@ -709,6 +893,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p }; if link != g.link { set_links(d, &g.ids, &link); + moved.push((g.link.path.clone(), g.key.clone(), true, link)); } } } @@ -720,6 +905,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p let link = f.link_hashed(hash, &g.link); if link != g.link { set_links(d, &g.ids, &link); + moved.push((g.link.path.clone(), g.key.clone(), false, link)); } continue; } @@ -745,11 +931,34 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p }; if link != g.link { set_links(d, &g.ids, &link); + moved.push((g.link.path.clone(), g.key.clone(), false, link)); } } (Status::Ok, None) => {} } } + // The objects in symbols, graph designs, patterns and opacity masks that show the same file and + // content, which have no ids here, whatever else their links hold. + if !moved.is_empty() { + let mut by_path: BTreeMap> = BTreeMap::new(); + for (path, key, document, new) in moved { + by_path.entry(path).or_default().push((key, document, new)); + } + let new = |n: &Node| { + let (l, key, document) = linked(n)?; + let m = by_path.get(&l.path)?; + m.iter().find(|(k, doc, _)| k == key && *doc == document.is_some()).map(|(_, _, new)| new) + }; + let pick = |n: &Node| new(n).is_some(); + let mut make = |n: &Node| { + let mut out = n.clone(); + if let (Some(link), Some(l)) = (new(n), link_mut(&mut out)) { + *l = link.clone(); + } + (out, None) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } out } @@ -772,35 +981,31 @@ fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { } /// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open -/// documents: read again from the new file, each as one Update Links step of its document. +/// documents, in the layers, symbols, graph designs, patterns and opacity masks: read again from +/// the new file, each as one Update Links step of its document. pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) { let path = absolute_path(path); - let targets: Vec<(usize, Vec)> = s + let targets: Vec = s .documents() .iter() .enumerate() .filter(|(_, st)| st.uid != saved) - .filter_map(|(i, st)| { - let mut ids = vec![]; - st.doc.walk(|n| { - if let NodeKind::PlacedDocument(p) = &n.kind - && p.link.path == path - { - ids.push(n.id.0); - } - }); - (!ids.is_empty()).then_some((i, ids)) + .filter(|(_, st)| { + let mut placed = false; + st.doc.visit_placed(|_, p| placed |= p.link.path == path); + placed }) + .map(|(i, _)| i) .collect(); if targets.is_empty() { return; } // Each document is updated as the active one, without switching tools. let back = s.active; - for (i, ids) in targets { + for i in targets { s.active = Some(i); // A file that can't be read leaves what its objects show: links.check reports it. - let _ = update(s, &json!({ "ids": ids })); + let _ = update_where(s, None, Some(&path)); } s.active = back; } @@ -826,31 +1031,45 @@ fn relative_path(target: &Path, base: &Path) -> Option { } /// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they -/// are already. +/// are already. Links in the layers, symbols, graph designs, patterns and opacity masks are all set. pub fn with_relative_paths(d: &Document, dest: &str) -> Option { let dest = absolute_path(dest); let dir = Path::new(&dest).parent()?; - let mut changes = vec![]; - d.walk(|n| { - if let Some((l, _, _)) = linked(n) { - let r = relative_path(Path::new(&l.path), dir); - if r != l.relative { - changes.push((n.id, r)); - } - } - }); - if changes.is_empty() { + let relative = |l: &LinkInfo| relative_path(Path::new(&l.path), dir); + let outdated = |l: &LinkInfo| relative(l) != l.relative; + let mut changed = false; + d.visit_images(|_, im| changed |= im.link.as_ref().is_some_and(outdated)); + d.visit_placed(|_, p| changed |= outdated(&p.link)); + if !changed { return None; } let mut d = d.clone(); - for (id, r) in changes { - if let Some(l) = d.node_mut(id).and_then(link_mut) { - l.relative = r; - } - } + d.update_links_where(outdated, |l| l.relative = relative(l)); Some(d) } +/// Sets each link of `d`, a copy of the document saved at `doc_path` that has no file of its own, +/// to the path where that document finds its file, so the copy finds the same files. +/// Links in the layers, symbols, graph designs, patterns and opacity masks are all set. +pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) { + // The new path of each link by what `locate` reads of it: its path, relative path and name. + let mut found: BTreeMap<(String, Option), Option> = BTreeMap::new(); + let mut look = |l: &LinkInfo| { + found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| locate(l, Some(doc_path)).map(|f| f.path)); + }; + d.visit_images(|_, im| im.link.iter().for_each(&mut look)); + d.visit_placed(|_, p| look(&p.link)); + found.retain(|(path, _), f| f.as_ref().is_some_and(|f| f != path)); + if found.is_empty() { + return; + } + d.update_links(|l| { + if let Some(Some(f)) = found.get(&(l.path.clone(), l.relative.clone())) { + l.path = f.clone(); + } + }); +} + // ---------- commands ---------- fn check(s: &mut Session, p: &Value) -> Result { @@ -876,28 +1095,92 @@ fn check(s: &mut Session, p: &Value) -> Result { } fn update(s: &mut Session, p: &Value) -> Result { + update_where(s, ids_param(p, "ids"), None) +} + +/// Update Links in the active document of `s` for objects `ids` (default: the linked files that +/// changed or show their preview), or with `path`, for the placed documents of the file at `path` +/// whatever their status. The objects in symbols, graph designs, patterns and opacity masks that +/// show the same content are read again too, and have no ids to report. +fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) -> Result { let st = s.doc()?; - let ids = ids_param(p, "ids"); let (mut reads, mut missing) = (vec![], vec![]); - for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) { + let asked = ids.is_some() || path.is_some(); + for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) { match probe(&g, st.path.as_deref()) { (Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)), - // Unchanged and showing the file: nothing to read unless asked for by id. - (Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {} + // Unchanged and showing the file: nothing to read unless asked for. + (Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {} // Unreadable as an image (or a document) counts as missing. (_, Some(mut f)) => match read_for(&mut f, &g, "links.update") { - Ok(read) => reads.push((g.ids, read)), + Ok(read) => reads.push((g, read)), Err(_) => missing.extend(ids_json(&g.ids)), }, } } - let updated: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); + let updated: Vec = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect(); if !reads.is_empty() { - s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, None)))?; + s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?; } Ok(json!({ "updated": updated, "missing": missing })) } +/// Which links `links.relink` points at the new file (the Replace choice of Relink). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Scope { + /// Each link its own file (`paths`). + Each, + /// The objects given or selected. + Selected, + /// Every object in the layers linked to the same file as one given or selected. + All, + /// The objects given or selected, then every other missing link whose file name is in the new + /// file's folder. + MissingInFolder, +} + +impl Scope { + fn from_id(id: &str) -> Option { + match id { + "each" => Some(Scope::Each), + "selected" => Some(Scope::Selected), + "all" => Some(Scope::All), + "missingInFolder" => Some(Scope::MissingInFolder), + _ => None, + } + } +} + +/// The error for `scope` and `paths` given one without the other, or `paths` beside ids, path or +/// folder. +fn each_mismatch(cmd: &str) -> EngineError { + bad(cmd, "scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder") +} + +/// `links.relink {scope: "each", paths}`: each entry's ids with its absolute file, entries naming +/// one file merged. A malformed entry, an id in two entries or an empty list is an error. +fn each_paths(p: &Value, cmd: &str) -> Result, String)>> { + let entries = p.get("paths").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| each_mismatch(cmd))?; + let mut seen = BTreeSet::new(); + let mut out: Vec<(Vec, String)> = vec![]; + for (i, e) in entries.iter().enumerate() { + let malformed = || bad(cmd, format!("paths entry {i} needs path and ids (object ids no other entry has)")); + let path = e.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(malformed)?; + let ids = e.get("ids").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(malformed)?; + let mut these = vec![]; + for v in ids { + let id = v.as_u64().map(NodeId).filter(|id| seen.insert(*id)).ok_or_else(malformed)?; + these.push(id); + } + let path = absolute_path(path); + match out.iter_mut().find(|(_, p)| *p == path) { + Some((ids, _)) => ids.extend(these), + None => out.push((these, path)), + } + } + Ok(out) +} + fn relink(s: &mut Session, p: &Value) -> Result { const C: &str = "links.relink"; // How the new file takes the image's place this time (default: its Placement Options). @@ -905,13 +1188,26 @@ fn relink(s: &mut Session, p: &Value) -> Result { None => None, Some(v) => Some(Preserve::from_id(v).ok_or_else(|| bad(C, format!("preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill")))?), }; + let scope = match p.get("scope").filter(|v| !v.is_null()) { + None => Scope::Selected, + Some(v) => v.as_str().and_then(Scope::from_id).ok_or_else(|| bad(C, format!("scope {v} is not each|selected|all|missingInFolder")))?, + }; let st = s.doc()?; let folder_param = str_param(p, "folder"); + match scope { + Scope::Each if p.get("path").is_some() || folder_param.is_some() || p.get("ids").is_some() => return Err(each_mismatch(C)), + Scope::Selected | Scope::All | Scope::MissingInFolder if p.get("paths").is_some() => return Err(each_mismatch(C)), + Scope::MissingInFolder if folder_param.is_some() => { + return Err(bad(C, "scope missingInFolder takes path: the folder of that file is searched for the other missing links")); + } + _ => {} + } + let each = if scope == Scope::Each { Some(each_paths(p, C)?) } else { None }; // With copy: the saved document's folder, which the file is copied into. - let copy_to = match (bool_or(p, "copy", false), str_param(p, "path")) { + let copy_to = match (bool_or(p, "copy", false), str_param(p, "path").is_some() || each.is_some()) { (false, _) => None, - (true, None) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")), - (true, Some(_)) => { + (true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")), + (true, true) => { let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?; Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?) } @@ -924,9 +1220,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { _ => None, }) }; - let ids: Vec = match ids_param(p, "ids") { - Some(ids) => ids, - None => { + let mut ids: Vec = match (&each, ids_param(p, "ids")) { + (Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(), + (None, Some(ids)) => ids, + (None, None) => { let selected: Vec = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect(); match folder_param { Some(_) if selected.is_empty() => { @@ -942,15 +1239,37 @@ fn relink(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); } + // All Instances: the other objects in the layers linked to the same files. + if scope == Scope::All { + let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect(); + let given: BTreeSet = ids.iter().copied().collect(); + let mut more = vec![]; + st.doc.walk(|n| { + if let Some((l, _, _)) = linked(n) + && paths.contains(l.path.as_str()) + && !given.contains(&n.id) + { + more.push(n.id); + } + }); + ids.extend(more); + } if copy_to.is_some() && ids.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { return Err(bad(C, "copy takes images only: relink placed documents where their files are")); } // (ids, the file) per file to read. let mut files: Vec<(Vec, String)> = vec![]; let mut not_found = vec![]; - match (str_param(p, "path"), folder_param) { - (Some(path), _) => files.push((ids, absolute_path(path))), - (None, Some(dir)) => { + match (each, str_param(p, "path"), folder_param) { + (Some(each), _, _) => files = each, + (None, Some(path), _) if scope == Scope::MissingInFolder => { + let path = absolute_path(path); + let given: BTreeSet = ids.iter().copied().collect(); + files.push((ids, path)); + missing_in_folder(st, &given, copy_to.is_some(), &mut files, &mut not_found); + } + (None, Some(path), _) => files.push((ids, absolute_path(path))), + (None, None, Some(dir)) => { for id in ids { let Some(link) = image(&id).flatten() else { continue }; let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy()); @@ -961,10 +1280,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { } } } - (None, None) => return Err(bad(C, "give path (a file) or folder")), + (None, None, None) => return Err(bad(C, "give path (a file) or folder")), } - let mut reads = vec![]; - let (mut copied, mut kept) = (vec![], vec![]); + // Every file is read and checked before any is copied. + let mut checked = vec![]; for (ids, path) in files { let bytes = read_file(&path)?; // Images show the file; placed documents each show their artboard or page of it. @@ -988,20 +1307,28 @@ fn relink(s: &mut Session, p: &Value) -> Result { for ((page, bounding, page_box), ids) in documents { sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?)); } - let to = match ©_to { - Some(dir) => { - let (to, new) = copy_into_folder(&path, &bytes, dir)?; + // The bytes are kept only to be copied, the file as it was checked. + let link = link_info(&path, &bytes); + let bytes = copy_to.is_some().then_some(bytes); + checked.push((path, link, bytes, images, img, sources)); + } + let mut reads = vec![]; + let (mut copied, mut kept): (Vec, Vec) = (vec![], vec![]); + for (path, link, bytes, images, img, sources) in checked { + let link = match (©_to, bytes) { + (Some(dir), Some(bytes)) => { + let (to, new) = copy_or_remove(&path, &bytes, dir, &copied)?; + let link = link_info(&to, &bytes); let pair = json!({ "from": path, "to": to }); if new { copied.push(pair) } else { kept.push(pair) } - to + link } - None => path, + _ => link, }; - let link = link_info(&to, &bytes); if let Some(img) = img { reads.push((images, Read::Image((link.clone(), img)))); } @@ -1011,7 +1338,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { } let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); if !reads.is_empty() { - s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve)))?; + s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?; } not_found.sort(); not_found.dedup(); @@ -1023,6 +1350,41 @@ fn relink(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// `links.relink {scope: "missingInFolder"}`: the other missing links of `st` (not `given`; only +/// images when `images_only`, as copy takes), each added to `files` with the file of its name in +/// the folder of the first file there; names not there go to `not_found`. +fn missing_in_folder( + st: &crate::DocState, + given: &BTreeSet, + images_only: bool, + files: &mut Vec<(Vec, String)>, + not_found: &mut Vec, +) { + let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return }; + for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) { + let rest: Vec = g.ids.iter().copied().filter(|id| !given.contains(id)).collect(); + if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing { + continue; + } + let found = absolute_path(&dir.join(g.link.name()).to_string_lossy()); + match files.iter_mut().find(|(_, p)| *p == found) { + Some((ids, _)) => ids.extend(rest), + None if file_stamp(&found).is_some() => files.push((rest, found)), + None => not_found.push(g.link.name().to_string()), + } + } +} + +/// [`copy_into_folder`]; when it fails, the copies `copied` (`{from, to}`) the same call made +/// before are removed, since nothing is relinked. +fn copy_or_remove(from: &str, bytes: &[u8], dir: &Path, copied: &[Value]) -> Result<(String, bool)> { + copy_into_folder(from, bytes, dir).inspect_err(|_| { + for to in copied.iter().filter_map(|pair| pair.get("to").and_then(Value::as_str)) { + let _ = std::fs::remove_file(to); + } + }) +} + /// `links.relink {copy}`: put the file at `from` (`bytes`, as just read) into the folder `dir`, /// never replacing a file there ([`fileio::copy_new`]) → where it is now, and whether it was /// copied (`false`: a file there with the same contents, of its name or a numbered one, is used). @@ -1071,7 +1433,9 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) -> /// `id`, else the first selected image. fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { - let id = id_param(p, "id").or_else(|| selected_images(st, |_| true).first().copied()).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; + let id = object_param(p, "id", cmd)? + .or_else(|| selected_images(st, |_| true).first().copied()) + .ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match image_of(&st.doc, id) { Some(_) => Ok(id), None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), @@ -1100,7 +1464,7 @@ fn placement_of(d: &Document, id: NodeId) -> Option { /// `id`, else the first selected image or placed document. fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some()); - let id = id_param(p, "id").or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; + let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match placement_of(&st.doc, id) { Some(_) => Ok(id), None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), @@ -1270,7 +1634,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { if !done.is_empty() || !documents.is_empty() { s.edit("Embed", |d, _| { for (ids, read) in reads { - install(d, &ids, read, Some(Preserve::Bounds))?; + install(d, &ids, read, Some(Preserve::Bounds), None)?; } for id in &done { if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) { @@ -1327,7 +1691,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result { let img = linked_image(&bytes)?; write_file(&path, &bytes)?; let read = (link_info(&path, &bytes), img); - s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds)))?; + s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?; Ok(json!({ "id": id.0, "path": path })) } diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index be02c16b1..464f24115 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -20,8 +20,8 @@ use super::edit::selected_roots; use super::expand::gradient_sampler; use super::pathops::Beside; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, check_numbers, check_switches, 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, + 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, }; mod blend; diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 9933b2071..7d7c21227 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -7,7 +7,7 @@ use super::common::{ use super::mesh::index_param; use super::{ Affine, Anchor, Appearance, Arc, Beside, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, Document, EngineError, Node, NodeId, NodeKind, - Paint, PathData, Point, Result, Session, Spine, Value, Vec2, bad, f64_req, id_param, json, live, number_or, ok, str_param, switch_or, + Paint, PathData, Point, Result, Session, Spine, Value, Vec2, bad, f64_req, json, live, number_or, ok, str_param, switch_or, }; /// The first blend among `roots`. @@ -160,7 +160,7 @@ fn edit_spine( label: &str, f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec, &mut [Arc]) -> Result, ) -> Result { - let id = match id_param(p, "id") { + let id = match crate::cmd::object_param(p, "id", cmd)? { Some(id) => id, None => *selected_of(s, is_blend).first().ok_or_else(|| bad(cmd, "select a blend or give `id`"))?, }; diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 20dd94fb3..54c65b0c7 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -136,7 +136,7 @@ pub fn specs() -> Vec { "Make with Warp…", ["Object", "Envelope Distort"], Some("Cmd+Alt+Shift+W"), - "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}", + "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", has_selection, env_make_warp ), @@ -145,7 +145,7 @@ pub fn specs() -> Vec { "Make with Mesh…", ["Object", "Envelope Distort"], Some("Cmd+Alt+M"), - "{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches → {id}", + "{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches over their bounds (type measured by its glyph outlines) → {id}", has_selection, env_make_mesh ), @@ -154,7 +154,7 @@ pub fn specs() -> Vec { "Make with Top Object", ["Object", "Envelope Distort"], Some("Cmd+Alt+C"), - "{ids?} the topmost selected path becomes the envelope of the others → {id}", + "{ids?} the topmost selected path becomes the envelope of the others (type maps from its glyph outline bounds) → {id}", has_multi, env_make_top ), @@ -172,7 +172,7 @@ pub fn specs() -> Vec { "Reset with Mesh…", ["Object", "Envelope Distort"], None, - "{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content", + "{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content, type measured by its glyph outlines), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content", has_envelope, env_reset_mesh ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 502ab0a7d..521f092b0 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -1,9 +1,9 @@ +use super::super::object::choice; use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wrap}; 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, check_switches, f64_or, f64_req, id_param, json, live, ok, str_param, - switch_or, + PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param, }; // ---------- Envelope ---------- @@ -17,7 +17,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result) -> Result false, - Some("horizontal") => true, - _ => given, + horizontal = match choice(p, cmd, "orientation", &["horizontal", "vertical"])? { + Some(o) => o == "horizontal", + None => given, }; Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) } @@ -85,7 +84,7 @@ pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result { let roots = roots_param(s, p)?; let st = s.doc()?; let nodes: Vec> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect(); - let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?; + let src = live::envelope_content_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?; let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] }; let defaults = envelope_defaults(s); let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?; @@ -185,7 +184,7 @@ fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Va /// `cur` with the options `p` sets. fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result { - let preserve_shape = match str_param(p, "preserveShape") { + let preserve_shape = match text_param(p, "preserveShape", cmd)? { Some(v) => PreserveShape::parse(v).ok_or_else(|| bad(cmd, "preserveShape must be clippingMask|transparency"))?, None => cur.preserve_shape, }; @@ -289,8 +288,8 @@ pub(super) fn env_release(s: &mut Session, _: &Value) -> Result { pub(super) fn env_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.options"; check_numbers(p, &["fidelity"], C)?; - check_numbers(p, WARP_NUMBERS, C)?; - check_switches(p, &["horizontal"], C)?; + // The warp keys are checked whatever is selected: with nothing or only mesh envelopes, too. + warp_kind(p, C, None)?; let fid = p.get("fidelity").and_then(Value::as_f64); if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) { return Err(bad(C, "fidelity must be 0..100")); diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 65bf15086..01721471c 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, MaskEdit, Node, NodeId, NodeKind, Selection}; -use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, id_param, switch_or}; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, object_param, switch_or}; /// Name of the temporary mask-editing layer. pub const MASK_EDIT_LAYER: &str = "Opacity Mask Editing"; @@ -117,7 +117,7 @@ fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.mask_edit.is_some() { return Err(bad(C, "already editing an opacity mask")); } - let id = id_param(p, "id").or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; let layer = start(s, id)?; Ok(json!({ "layer": layer.0 })) } @@ -136,6 +136,7 @@ fn start(s: &mut Session, id: NodeId) -> Result { fn view_mask(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.viewOpacityMask"; + let given = object_param(p, "id", C)?; let st = s.doc()?; let editing = st.doc.mask_edit.map(|m| m.object); if !switch_or(p, "on", st.shown_mask().is_none(), C)? { @@ -144,7 +145,7 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { st.revision += 1; return Ok(json!({ "on": false, "id": id.map(|i| i.0) })); } - let id = id_param(p, "id").or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; match editing { Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")), Some(_) => {} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 95f8c3e8f..6992f42ab 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -106,7 +106,7 @@ pub fn specs() -> Vec { "Reset Bounding Box", ["Object", "Transform"], None, - "{} square the bounding box of the selected objects to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset}", + "{} square the bounding box of the selected objects, and of every object inside them (the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks), to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset, the objects inside included}", has_selection, reset_bounding_box ), @@ -178,7 +178,7 @@ pub fn specs() -> Vec { "Rearrange All Artboards…", ["Object", "Artboards"], None, - "{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}", + "{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with lockedAndHidden), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (default: prefs moveLockedWithArtboard), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}", has_doc, rearrange_artboards ), @@ -223,21 +223,63 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) { // ---------- Reset Bounding Box ---------- +/// Squares each selected object and every object inside it: the children of groups, compound paths, +/// clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. fn reset_bounding_box(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; let d = &s.doc()?.doc; - let turned: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.bbox_angle != 0.0)).collect(); - if !turned.is_empty() { + let mut changed = 0; + let squared: Vec<(NodeId, Node)> = ids + .into_iter() + .filter_map(|id| { + let (n, count) = squared(d.node(id)?); + changed += count; + Some((id, n?)) + }) + .collect(); + if !squared.is_empty() { s.edit("Reset Bounding Box", |d, _| { - for id in &turned { - if let Some(n) = d.node_mut(*id) { - n.bbox_angle = 0.0; + for (id, n) in squared { + if let Some(slot) = d.node_mut(id) { + *slot = n; } } Ok(()) })?; } - Ok(json!({ "changed": turned.len() })) + Ok(json!({ "changed": changed })) +} + +/// A copy of `n` with the angle of every object in its subtree set to 0, or `None` when no box in +/// it stands at an angle, and the number of objects squared. The subtree takes in children and the +/// art of linked opacity masks (an unlinked mask's art turns on its own); layers have no box. Only +/// the nodes on the way to a turned object are copied. +fn squared(n: &Node) -> (Option, usize) { + let mut out = (!n.is_layer() && n.bbox_angle != 0.0).then(|| { + let mut c = n.clone(); + c.bbox_angle = 0.0; + c + }); + let mut count = usize::from(out.is_some()); + for (i, c) in n.children().into_iter().flatten().enumerate() { + let (child, k) = squared(c); + count += k; + if let Some(child) = child + && let Some(slot) = out.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) + { + *slot = Arc::new(child); + } + } + if let Some(m) = n.extras().mask.as_ref().filter(|m| m.linked) { + let (art, k) = squared(&m.art); + count += k; + if let Some(art) = art + && let Some(m) = out.get_or_insert_with(|| n.clone()).extras_if_any().and_then(|e| e.mask.as_mut()) + { + m.art = Arc::new(art); + } + } + (out, count) } // ---------- Lock / Hide ---------- @@ -1274,7 +1316,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5); let move_art = switch_or(p, "moveArtwork", true, CMD)?; check_switches(p, &["patterns"], CMD)?; - let locked_and_hidden = s.prefs.move_locked_with_artboard; + let locked_and_hidden = super::panelcmds::locked_and_hidden(s, p, move_art, CMD)?; let d = &s.doc()?.doc; let rects: Vec = d.artboards.iter().map(|a| a.rect).collect(); let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect(); @@ -1291,7 +1333,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { // With moveArtwork, each top-level object (a child of a layer or sublayer) moves with the // artboard its center is on, with symbol instances measured by their art as // `Document::art_on_artboard` measures them; locked and hidden art, and art on a locked - // artboard, moves only when prefs moveLockedWithArtboard is on. + // artboard, moves only with `locked_and_hidden`. fn objects<'a>(n: &'a Node, all: bool, out: &mut Vec<&'a Node>) { for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) { if c.is_layer() { objects(c, all, out) } else { out.push(c) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bac3540fa..ade05bd95 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -16,7 +16,7 @@ use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_tra use super::opacitymask::percent; use super::{ CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_multi, has_selection, - has_selection_or_guides, id_param, ids_param, matrix_param, ok, point_param, str_param, targets, + has_selection_or_guides, ids_param, matrix_param, object_param, ok, point_param, str_param, targets, }; use crate::{EngineError, LastTransform}; diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index fe350a144..156b35824 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -66,7 +66,7 @@ fn measure(s: &Session, p: &Value, cmd: &str) -> Result { Ok(Measure { preview, point: s.prefs.align_glyph_bounds_point, area: s.prefs.align_glyph_bounds_area }) } -/// Param `key` of the Align commands, Shear, Unlock All, Show All and `text.setFormat`'s lists: one +/// Param `key` of the Align commands, Shear, Unlock All, Show All, a warp's `orientation` and `text.setFormat`'s lists: one /// of `values`, or `None` when it is missing or null. Any other value is an error that lists `values`. pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 2e7d2a8e4..7aef60fca 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -1,5 +1,5 @@ use super::align::choice; -use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, id_param, json, ok, selected_roots}; +use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, json, object_param, ok, selected_roots}; pub(super) enum Arrange { Front, @@ -293,7 +293,7 @@ pub(super) fn show_all(s: &mut Session, p: &Value) -> Result { } pub(super) fn isolate(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id") + let id = object_param(p, "id", "object.isolate")? .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) .ok_or_else(|| bad("object.isolate", "missing id"))?; let guides_locked = s.menu.guides_locked; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 390f6ef49..bbb5f35d3 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -128,7 +128,7 @@ pub fn proxy_specs() -> Vec { "Recent Colors", [], None, - "{} → {colors: [{hex, color}] newest first (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}", + "{} → {colors: [{hex, color}] newest first, at most 30, each once, kept across documents for the session (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}", always, |s, _| { let c = |c: &Color| json!({"hex": c.to_hex(), "color": c}); @@ -152,8 +152,9 @@ pub fn proxy_specs() -> Vec { } impl Session { - /// How many colours the Recent Colors rows keep. - pub const RECENT_MAX: usize = 10; + /// How many colors Recent Colors keeps. The Session holds the list, so it stays the same when + /// another document is opened or made active. + pub const RECENT_MAX: usize = 30; /// Remember an applied paint: a solid colour becomes the last colour and the newest recent /// colour, a gradient the last gradient. During a live preview this waits for the commit. diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2e3ae4301..6eeafd7c3 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun}; use vectorcraft_geom::{Rect, Vec2}; -use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices}; +use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or}; use crate::{Clipboard, EngineError}; pub fn specs() -> Vec { @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Duplicate Artboard", ["Window", "Artboards"], None, - "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", + "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", has_doc, artboard_duplicate ), @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Copy Artboard", [], None, - "{index? | indices?, selectedArtboards?, art?} (as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}", has_doc, |s, p| { boards_and_art_noted(s, p, "artboard.copy")?; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Cut Artboard", [], None, - "{index? | indices?, selectedArtboards?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless lockedAndHidden); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", has_doc, |s, p| { boards_and_art_noted(s, p, "artboard.cut")?; @@ -253,14 +253,24 @@ pub(crate) fn refuse_locked(d: &vectorcraft_doc::Document, indices: &[usize]) -> } } +/// Does locked and hidden art go with an artboard (`lockedAndHidden` param, else prefs +/// moveLockedWithArtboard)? Noted in the journal when `art` goes along, so a replay takes the same +/// art whatever the preference is then. +pub(crate) fn locked_and_hidden(s: &mut Session, p: &Value, art: bool, cmd: &str) -> Result { + let all = switch_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard, cmd)?; + if art { + s.note_journal("lockedAndHidden", json!(all)); + } + Ok(all) +} + /// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden -/// ones only with prefs moveLockedWithArtboard), in paint order. With `moving` (a move or a cut), -/// the art also on another locked artboard stays unless prefs moveLockedWithArtboard -/// ([`vectorcraft_doc::Document::art_moving_with`]); a copy takes it. -pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Result> { +/// ones only when `all`), in paint order. With `moving` (a move or a cut), the art also on another +/// locked artboard stays unless `all` ([`vectorcraft_doc::Document::art_moving_with`]); a copy +/// takes it. +pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, all: bool, moving: bool) -> Result> { let d = &s.doc()?.doc; let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; - let all = s.prefs.move_locked_with_artboard; Ok(match (art, moving) { (false, _) => vec![], (true, false) => d.art_on_artboard(rect, all), @@ -270,11 +280,11 @@ pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Re /// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object /// counted once, with the first of them it is on (artboards can overlap). -pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, moving: bool) -> Result)>> { +pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, all: bool, moving: bool) -> Result)>> { let mut seen = std::collections::HashSet::new(); let mut arts = vec![]; for &i in indices { - let art = artboard_art(s, i, art, moving)?.into_iter().filter(|id| seen.insert(*id)).collect(); + let art = artboard_art(s, i, art, all, moving)?.into_iter().filter(|id| seen.insert(*id)).collect(); arts.push((i, art)); } Ok(arts) @@ -291,7 +301,8 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { check_switches(p, &["patterns"], "artboard.duplicate")?; let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?; - let arts = artboards_art(s, &indices, art, false)?; + let locked_art = locked_and_hidden(s, p, art, "artboard.duplicate")?; + let arts = artboards_art(s, &indices, art, locked_art, false)?; let all: Vec = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect(); let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() }; let (index, ids) = s.edit("Duplicate Artboard", |d, _| { @@ -316,8 +327,10 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { /// Artboard Copy / Cut: the artboards and their art onto the clipboard (pasting brings them all); /// a cut then deletes them. pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result { - let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?; - let arts = artboards_art(s, &indices, art, cut)?; + let cmd = if cut { "artboard.cut" } else { "artboard.copy" }; + let (indices, art) = boards_and_art(s, p, cmd)?; + let locked_art = locked_and_hidden(s, p, art, cmd)?; + let arts = artboards_art(s, &indices, art, locked_art, cut)?; let st = s.doc()?; if cut && st.doc.artboards.len() <= indices.len() { return Err(EngineError::Other("a document needs at least one artboard".into())); diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 1ca9bf233..f2338d139 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -8,15 +8,19 @@ //! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't //! described key by key, so only the top level is checked. //! -//! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error -//! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the -//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and +//! [`Session::execute`] answers a key the description doesn't name with a parameter error +//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the +//! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a +//! misspelled `widht` no longer runs the command with its default. So are the `params` of an +//! effect (#1592): `effect.apply` and //! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its -//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes -//! what it is given, so the journal of an older version still replays, except for an effect's -//! `color` that the effect can't read and a Levels or Curves `channel` that the effect doesn't -//! have, which `effect.apply` and `effect.setParams` refuse themselves ([`check_effect_color`], -//! [`check_effect_channel`]). +//! default, with a parameter error ([`check_effect_params`]). +//! +//! Saved steps play through [`Session::replay`] instead: a journal or an action recorded by an older +//! version, with a key a command no longer reads or an effect parameter it no longer takes, still +//! plays. Even there, an effect's `color` that the effect can't read and a Levels or Curves +//! `channel` that the effect doesn't have are refused by `effect.apply` and `effect.setParams` +//! themselves ([`check_effect_color`], [`check_effect_channel`]). use serde_json::Value; @@ -473,10 +477,16 @@ pub const UNCHECKED: &[&str] = &[ "webExport.settings", ]; -/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and +/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, and /// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have /// passes: running it reports that. pub fn check_params(id: &str, params: &Value) -> Result<()> { + check_params_in(id, params, false) +} + +/// [`check_params`] for a step of a batch when `in_batch`: batches can't nest, so the steps of a +/// `command.batch` or `action.batch` step aren't checked (running it reports that), however deep they go. +fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> { let Some(spec) = find_command(id) else { return Ok(()) }; if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) = (id, params.get("effect").or_else(|| params.get("id")).and_then(Value::as_str), params.get("params")) @@ -487,10 +497,10 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> { return Ok(()); } check_keys(id, spec.params, params)?; - if id == "command.batch" { + if matches!(id, "command.batch" | "action.batch") && !in_batch { for step in params.get("commands").and_then(Value::as_array).into_iter().flatten() { if let (Some(step_id), Some(step_params)) = (step.get("command").and_then(Value::as_str), step.get("params")) { - check_params(step_id, step_params)?; + check_params_in(step_id, step_params, true)?; } } } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5fd0f5443..85e4dc8c1 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Outline Stroke", ["Object", "Path"], None, - "{} replace stroked paths with filled outlines → {ids}", + "{} replace stroked paths with filled outlines; a dimension whose lines are outlined, or selected with the art it measures when that art is outlined, becomes a plain group of its art → {ids}", has_selection, outline_stroke ), @@ -980,6 +980,8 @@ pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(& fn outline_stroke(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Outline Stroke", |d, sel| { + let before = d.clone(); + let mut ended = super::dimension::end_links(d, &roots); let brushes = vectorcraft_brush::library(d); let mut new_sel = vec![]; let mut changed = 0; @@ -988,6 +990,9 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { new_sel.push(id); changed += n; } + // A dimension whose source was outlined stays plain: its link no longer fits that art. + ended.retain(|e| e.source_kept(&before, d)); + super::dimension::restore_unchanged(d, ended); if changed == 0 { return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into())); } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 8f1e99320..614a37d21 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -377,20 +377,24 @@ pub(crate) fn pattern_done(s: &mut Session, _: &Value) -> Result { if editing(s).is_some() { s.end_text_edit(); } - let name = s.edit("Done Editing Pattern", |d, sel| { - let (pe, art) = leave_edit(d, sel)?; - if let Some(def) = d.pattern_mut(&pe.pattern) { - def.art = art; - if def.size_tile_to_art { - def.fit_tile_to_art(); - } - } - Ok(pe.pattern) - })?; + let name = s.edit("Done Editing Pattern", finish_edit)?; after_edit_mode(s, None)?; Ok(json!({ "name": name })) } +/// Leave pattern editing mode in `d`, storing the editing layer's art as the pattern's tile art +/// (resizing the tile to the art when the pattern does so) → the pattern's name. +pub(crate) fn finish_edit(d: &mut Document, sel: &mut Selection) -> Result { + let (pe, art) = leave_edit(d, sel)?; + if let Some(def) = d.pattern_mut(&pe.pattern) { + def.art = art; + if def.size_tile_to_art { + def.fit_tile_to_art(); + } + } + Ok(pe.pattern) +} + fn pattern_cancel(s: &mut Session, _: &Value) -> Result { s.edit("Cancel Pattern Editing", |d, sel| { let (pe, _) = leave_edit(d, sel)?; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index d78ca2782..0de33a22c 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -47,7 +47,7 @@ use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_pdf::CropTo; use super::fileio::{self, Format, RasterImage}; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, id_param, str_param, unique_name}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, object_param, str_param, unique_name}; use crate::{EngineError, MAX_COORD}; const PLACE: &str = "file.place"; @@ -866,7 +866,7 @@ pub fn read_info(p: &Value) -> Result { pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { const C: &str = "image.info"; let st = s.doc()?; - let id = match (id_param(p, "id"), &st.selection.objects[..]) { + let id = match (object_param(p, "id", C)?, &st.selection.objects[..]) { (Some(id), _) => id, (None, [id]) => *id, _ => return Err(bad(C, "select one image or pass id")), diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 8d1bb0408..6593f7a79 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -13,6 +13,18 @@ //! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`), //! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with //! Cancel ([`FilterRun`]). +//! +//! A plug-in whose manifest lists permissions installs once the user grants them +//! (`plugin.install {grant}`, recorded with the module's hash and kept in the plug-in store), and +//! a filter does only what they allow: without `doc.read` it is given no objects, without +//! `doc.write` an output that changes the document is refused, and without `state` it neither +//! reads nor writes the data it keeps with the document. +//! +//! A plug-in of API level 2 that lists `progress` may import the host's `vc_progress`, +//! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a +//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its +//! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log +//! under its id. use std::sync::{Arc, Mutex}; @@ -22,6 +34,8 @@ use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registr use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; +use vectorcraft_plugins::permissions::{self, Grant, Permission}; + /// What a plug-in error looks like to the user. A run the caller cancelled changed nothing. fn err(e: vectorcraft_plugins::Error) -> EngineError { match e { @@ -37,6 +51,7 @@ pub fn describe(p: &Plugin) -> Value { "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), "size": p.size(), "source": p.source(), + "permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api, }); match m.kind { Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)), @@ -122,15 +137,17 @@ impl FilterRun { } // Bad parameters fail before anything runs. let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); + let may = m.permissions(); let st = s.doc()?; let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); - let inputs = objects::filter_targets(&st.doc, &selected); + // Without `doc.read` a filter is given no objects: it can only add new ones. + let inputs = if may.contains(&Permission::DocRead) { objects::filter_targets(&st.doc, &selected) } else { Vec::new() }; let parent = st.insertion_parent(); let bounds = st.doc.bounds_of(&inputs, false); let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]); // What the plug-in keeps with the document, as it last wrote it. - let data = st.doc.plugin_data(&m.id).cloned().unwrap_or(Value::Null); + let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); let input = objects::encode_input(&st.doc, &inputs); let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; @@ -175,13 +192,23 @@ impl FilterRun { } /// Applies the plug-in's `output` to the active document as one undo step: its objects, and what - /// it keeps with the document from now on. + /// it keeps with the document from now on. An output its permissions don't allow is refused, + /// and the document stays as it was. fn finish(self, s: &mut Session, output: &[u8]) -> Result { let m = self.plugin.manifest(); + let may = m.permissions(); let out = objects::decode_output(output).map_err(err)?; + if out.document_data.is_some() && !may.contains(&Permission::State) { + return Err(not_granted(&m.name, Permission::State)); + } + let may_write = may.contains(&Permission::DocWrite); let (inputs, parent) = (self.inputs, self.parent); let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { + let before = (!may_write).then(|| d.layers.clone()); let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?; + if before.is_some_and(|b| b != d.layers) { + return Err(not_granted(&m.name, Permission::DocWrite)); + } if let Some(data) = out.document_data { d.set_plugin_data(&m.id, data); } @@ -192,6 +219,12 @@ impl FilterRun { } } +/// The error of a filter `name` whose output needs `permission`, which it wasn't granted. +fn not_granted(name: &str, permission: Permission) -> EngineError { + let (name, p) = (permissions::display_text(name, permissions::MAX_SHOWN_NAME), permission.name()); + err(vectorcraft_plugins::Error::Output(format!("“{name}” wasn't granted `{p}`, which its output needs: nothing was changed"))) +} + /// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its /// parameters and `_context` (what it keeps with the document included), and applies its output as /// one undo step ([`objects::apply_output`]; effect records as [`admit_effects`] admits them), with @@ -283,13 +316,37 @@ fn load_given(c: &str, p: &Value) -> Result<(Plugin, Vec)> { } } +/// The permissions `grant` lists in command `c`'s params: `None` when it is left out (or null). +/// Anything but a list of permission names is an error. +fn grant_param(c: &str, p: &Value) -> Result>> { + let Some(v) = p.get("grant").filter(|v| !v.is_null()) else { + return Ok(None); + }; + let names = v.as_array().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?; + let mut out = Vec::with_capacity(names.len()); + for n in names { + let name = n.as_str().ok_or_else(|| bad(c, "`grant` must be a list of permission names"))?; + let shown = permissions::display_text(name, permissions::MAX_SHOWN_FIELD); + out.push(Permission::parse(name).ok_or_else(|| bad(c, format!("`grant` names an unknown permission `{shown}`")))?); + } + Ok(Some(out)) +} + /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, keeps it for the next start and installs it → its -/// `plugin.list` entry. +/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id +/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant +/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); + let grant = grant_param(C, p)?; let (plugin, bytes) = load_given(C, p)?; + // `hash` pins the module the user was shown: one with other bytes is refused. + if let Some(h) = p.get("hash").filter(|v| !v.is_null()) + && h.as_str() != Some(plugin.hash()) + { + return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed")); + } if !replace && registry::get(plugin.id()).is_some() { return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); } @@ -304,14 +361,33 @@ fn install(s: &mut Session, p: &Value) -> Result { ), )); } + // A plug-in that lists permissions installs once the user grants them: whoever installs it + // asks them first, unless they already granted its id all of them. + let m = plugin.manifest(); + let wanted = m.permissions(); + if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { + let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME); + let asked = permissions::names(&wanted).join(", "); + return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees"))); + } + if m.asks_permissions() { + let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; + registry::grant(g.clone()); + s.store_grant(&m.id, Some(g)); + } // Installed plug-ins stay installed after a restart (#2541). s.store_plugin(plugin.id(), &bytes); Ok(describe(®istry::install(plugin))) } -/// `plugin.inspect`: what installing the module would install, without installing it. -fn inspect(_: &mut Session, p: &Value) -> Result { - load_given("plugin.inspect", p).map(|(plugin, _)| describe(&plugin)) +/// `plugin.inspect`: what installing the module would install, without installing it, and whether +/// that needs the user to grant its permissions first (`needsApproval`). +fn inspect(s: &mut Session, p: &Value) -> Result { + load_given("plugin.inspect", p).map(|(plugin, _)| { + let mut v = describe(&plugin); + v["needsApproval"] = json!(!s.plugin_approved(&plugin)); + v + }) } /// The plug-in in the `.wasm` file `path`, and its module. @@ -328,35 +404,40 @@ fn load_path(_: &str) -> Result<(Plugin, Vec)> { Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into())) } -/// The plug-in folder last loaded from the preferences, and what happened. -static FOLDER: Mutex> = Mutex::new(None); +/// The plug-in folder last loaded, the plug-in store whose grants it was loaded with, and what +/// happened. +static FOLDER: Mutex, Value)>> = Mutex::new(None); -/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since -/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only. +/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and it, or the +/// session's plug-in store, has changed since the last load. Runs whenever preferences are applied +/// (startup, `prefs.set`). Native only. pub(crate) fn sync_prefs(s: &Session) { let folder = s.prefs.plugins_folder.trim(); if folder.is_empty() { return; } let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); - if last.as_ref().is_some_and(|(f, _)| f == folder) { + if last.as_ref().is_some_and(|(f, store, _)| f == folder && *store == s.plugin_dir) { return; } - *last = Some((folder.to_string(), load_folder(folder))); + *last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder))); } +/// Loads the plug-ins in `folder`, those that list permissions only when `s` has them granted +/// ([`Session::plugin_approved`], with the store's grants read once). #[cfg(not(target_arch = "wasm32"))] -fn load_folder(folder: &str) -> Value { +fn load_folder(s: &Session, folder: &str) -> Value { if let Err(e) = crate::file_access::check_read(folder) { return json!({"folder": folder, "error": e}); } // A file in it may be a link to one elsewhere. - match registry::load_folder_where(std::path::Path::new(folder), |f| match f.to_str() { + let allow = |f: &std::path::Path| match f.to_str() { Some(f) => crate::file_access::check_read(f), // A lossy name would check another file than the one loaded. None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), - }) { + }; + match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { Ok(r) => { json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) } @@ -365,7 +446,7 @@ fn load_folder(folder: &str) -> Value { } #[cfg(target_arch = "wasm32")] -fn load_folder(folder: &str) -> Value { +fn load_folder(_: &Session, folder: &str) -> Value { json!({"folder": folder, "error": "plug-in folders are not available on the web"}) } @@ -378,16 +459,16 @@ fn reload(s: &mut Session, p: &Value) -> Result { if folder.is_empty() { return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder")); } - let report = load_folder(&folder); + let report = load_folder(s, &folder); if let Some(e) = report.get("error").and_then(Value::as_str) { return Err(EngineError::Other(e.to_string())); } - *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone())); + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report.clone())); Ok(report) } fn list(_: &mut Session, _: &Value) -> Result { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone()); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder})) } @@ -402,6 +483,9 @@ fn info(_: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + // Installed again, it asks again. + registry::revoke(id); + s.store_grant(id, None); s.unstore_plugin(id); if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } } @@ -415,7 +499,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), @@ -442,7 +526,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -451,7 +535,7 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)", always, inspect ), @@ -461,7 +545,7 @@ pub fn specs() -> Vec { "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 94a922400..a702f1580 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -152,6 +152,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool), p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool), p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool), + p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool), // Selection & Anchor Display p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")), p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool), @@ -247,6 +248,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("measurementLabels", "Smart Guides", "Display Options", "Measurement Labels", bool), p!("spacingGuides", "Smart Guides", "Display Options", "Spacing Guides", bool), p!("distanceGuides", "Smart Guides", "Display Options", "Invoke Distance Guides", bool), + p!("snapToLastLocation", "Smart Guides", "Display Options", "Snap to Last Location", bool), p!("snappingTolerance", "Smart Guides", "", "Snapping Tolerance", num(0.0, 40.0, "pt")), p!("snapTangent", "Smart Guides", "Geometric Guides", "Snap to Tangent", bool), p!("snapPerpendicular", "Smart Guides", "Geometric Guides", "Snap to Perpendicular", bool), @@ -422,6 +424,9 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // The Align panel menu's Align to Glyph Bounds: Align and Distribute measure type by its glyphs' ink. p!("alignGlyphBoundsPoint", "Type", "Align to Glyph Bounds", "Point Text", bool), p!("alignGlyphBoundsArea", "Type", "Align to Glyph Bounds", "Area Text", bool), + // Artboard Options › Global: the canvas darkens the area outside the artboards while the Artboard tool is active. + p!("fadeRegionOutsideArtboard", "General", "Artboard Options", "Fade Region Outside Artboard", bool), + p!("artboardUpdateWhileDragging", "General", "Artboard Options", "Update While Dragging", bool), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 511e8ce4f..44ec938bc 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -2,7 +2,7 @@ //! art retained underneath. Recipes select topology regions; source edits re-evaluate them in //! the same undo transaction. Rendering and exporters only see ordinary groups and paths. use super::pathops::Beside; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, point_param}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, @@ -785,15 +785,15 @@ fn end_isolation(s: &mut Session) -> Result<()> { st.isolation = editing_layer(&st.doc); Ok(()) } -fn target(s: &Session, p: &Value) -> Result { - let id = id_param(p, "id") +fn target(s: &Session, p: &Value, cmd: &str) -> Result { + let id = object_param(p, "id", cmd)? .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) - .ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; - s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; + .ok_or_else(|| bad(cmd, "select a Shaper group"))?; + s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad(cmd, "select a Shaper group"))?; Ok(id) } fn release(s: &mut Session, p: &Value) -> Result { - let id = target(s, p)?; + let id = target(s, p, "shaper.release")?; let ids = s.edit("Release Shaper Group", |d, sel| { let original = d.node(id).map(sources).ok_or(EngineError::NoNode(id))?; let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; @@ -809,7 +809,7 @@ fn release(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) } fn expand(s: &mut Session, p: &Value) -> Result { - let id = target(s, p)?; + let id = target(s, p, "shaper.expand")?; s.edit("Expand Shaper Group", |d, sel| { let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; n.extras_mut().shaper = None; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index a15ea9ff4..36780707f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -20,7 +20,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, index, ok, str_param, unique_name}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name}; use crate::EngineError; pub fn specs() -> Vec { @@ -290,7 +290,7 @@ fn style_index(d: &Document, name: &str) -> Result { /// The object a style is made from: `id`, else the first selected object. fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node)> { - let id = match id_param(p, "id") { + let id = match object_param(p, "id", cmd)? { Some(id) => id, None => super::edit::selected_roots(s)?.first().copied().ok_or_else(|| bad(cmd, "select an object (or pass `id`)"))?, }; diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 7944e59a0..f19861685 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -1,16 +1,15 @@ //! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting. +use std::collections::{HashMap, HashSet}; + use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent}; use vectorcraft_doc::pattern::pattern_paint; use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors}; use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; -use super::{ - CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or, - unique_name, -}; +use super::{CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or}; use crate::EngineError; pub fn specs() -> Vec { @@ -195,10 +194,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { let requested = name_param(p, "name").filter(|_| paints.len() == 1); let names = s.edit("New Swatch", |d, _| { let mut names = vec![]; + let mut free = d.free_swatch_names(); for paint in paints { let name = match &requested { - Some(n) => d.free_swatch_name(n), - None => d.new_swatch_name(&paint), + Some(n) => free.take(n), + None => free.take_new(&paint), }; // A tint swatch's kind is its base's. let tint = matches!(paint, Paint::Solid { swatch: Some(_), .. }); @@ -271,36 +271,116 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { /// each). A tint (a linked colour that differs from its swatch's) brings its swatch, and with /// `tints` its own colour too. Links to missing swatches count as unlinked. fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec, colors: &mut Vec) { + let by_name = swatches_by_name(d); + let mut seen_swatches: HashSet = swatches.iter().cloned().collect(); + let mut seen_colors: HashSet = colors.iter().filter_map(color_key).collect(); for n in ids.iter().filter_map(|id| d.node(*id)) { node_colors(n, &mut |c, link| { - let sw = link.and_then(|l| d.swatch(l)).filter(|w| w.paint.color().is_some()); + let sw = link.and_then(|l| by_name.get(l).copied()).filter(|w| w.paint.color().is_some()); if let Some(w) = sw - && !swatches.contains(&w.name) + && !seen_swatches.contains(&w.name) { + seen_swatches.insert(w.name.clone()); swatches.push(w.name.clone()); } let own = sw.is_none_or(|w| tints && w.paint.color() != Some(*c)); - if own && !colors.contains(c) { + // A color with a NaN component equals no color, so it is never already listed. + if own && color_key(c).is_none_or(|k| seen_colors.insert(k)) { colors.push(*c); } }); } } -/// Link the solid paints in the subtrees of `ids` whose colour is one of `links`' to that swatch, -/// unless they already link to an existing swatch. Returns the number of paints linked. +/// Every swatch by name, as [`Document::swatch`] finds it: the first swatch of each name, and the +/// built-in Registration swatch when no swatch has its name. +fn swatches_by_name(d: &Document) -> HashMap<&str, &Swatch> { + let mut by_name = HashMap::new(); + for w in d.swatches_iter().chain([vectorcraft_color::swatch::registration()]) { + by_name.entry(w.name.as_str()).or_insert(w); + } + by_name +} + +/// A color's model and the bits of its components, with -0.0 read as 0.0: two keys are equal +/// exactly when the colors are equal (`==`). `None` for a color with a NaN component, which +/// equals no color. +type ColorKey = (u8, [u32; 4]); + +fn color_key(c: &Color) -> Option { + let (model, v) = match *c { + Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]), + Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]), + Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]), + Color::Lab { l, a, b } => (3, [l, a, b, 0.0]), + }; + Some((model, [float_key(v[0])?, float_key(v[1])?, float_key(v[2])?, float_key(v[3])?])) +} + +/// The bits of `v`, with -0.0 read as 0.0, so keys are equal exactly when the values are; `None` +/// for NaN. +fn float_key(v: f32) -> Option { + if v.is_nan() { + None + } else if v == 0.0 { + Some(0) + } else { + Some(v.to_bits()) + } +} + +/// A gradient stop's offset, color, opacity, midpoint, swatch link, tint and process plates, as +/// keys. +type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>); + +/// A gradient's kind, method, dither and stops, as keys. +type GradientKey<'a> = (u8, u8, bool, Vec>); + +/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when +/// any of its numbers is NaN, so the gradient equals no gradient. +fn gradient_key(g: &Gradient) -> Option> { + let kind = match g.kind { + GradientKind::Linear => 0, + GradientKind::Radial => 1, + GradientKind::Freeform => 2, + }; + let method = match g.method { + GradientMethod::Classic => 0, + GradientMethod::Perceptual => 1, + }; + Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::>()?)) +} + +/// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN. +fn stop_key(s: &GradientStop) -> Option> { + let plates = match s.plates { + Some(p) => Some([float_key(p[0])?, float_key(p[1])?, float_key(p[2])?, float_key(p[3])?]), + None => None, + }; + Some((float_key(s.offset)?, color_key(&s.color)?, float_key(s.opacity)?, float_key(s.midpoint)?, s.swatch.as_deref(), float_key(s.tint)?, plates)) +} + +/// Link the solid paints in the subtrees of `ids` whose color is one of `links`' to that swatch +/// (the first one listed for the color), unless they already link to an existing swatch or to the +/// built-in Registration swatch. Returns the number of paints linked. fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> usize { if links.is_empty() { return 0; } - let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); + let live: HashSet = d.swatches_iter().chain([vectorcraft_color::swatch::registration()]).map(|w| w.name.clone()).collect(); + let mut by_color: HashMap = HashMap::new(); + for (c, n) in links { + if let Some(k) = color_key(c) { + by_color.entry(k).or_insert(n); + } + } d.map_solid_paints_in(ids, &mut |c, link, _| { if link.as_ref().is_some_and(|l| live.contains(l)) { return false; } - match links.iter().find(|(lc, _)| lc == c) { - Some((_, n)) => { - *link = Some(n.clone()); + match color_key(c).and_then(|k| by_color.get(&k)) { + Some(n) => { + *link = Some((*n).clone()); true } None => false, @@ -346,8 +426,11 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { } // The global swatches made from the art's colours, which its unlinked paints link to. let mut links: Vec<(Color, String)> = vec![]; + let mut free = d.free_swatch_names(); + free.insert(&name); + group.swatches.iter().for_each(|sw| free.insert(&sw.name)); for (c, global) in new { - let nm = unique_name(&color_name(c), |n| n == name || d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n)); + let nm = free.take(&color_name(c)); if global { links.push((c, nm.clone())); } @@ -798,10 +881,12 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result { let (mut linked_to, mut colors) = (vec![], vec![]); artwork_colors(d, &ids, false, &mut linked_to, &mut colors); // Colours a solid swatch already has are skipped. - colors.retain(|c| !d.swatches_iter().any(|w| w.paint.color() == Some(*c))); + let have: HashSet = d.swatches_iter().filter_map(|w| w.paint.color()).filter_map(|c| color_key(&c)).collect(); + colors.retain(|c| color_key(c).is_none_or(|k| !have.contains(&k))); + let mut free = d.free_swatch_names(); let mut links = vec![]; for c in colors { - let name = d.free_swatch_name(&color_name(c)); + let name = free.take(&color_name(c)); d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false }); links.push((c, name)); } @@ -813,10 +898,11 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result { fn swatch_unused(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; + let by_name = swatches_by_name(d); // Links with their tints, so a tint swatch is used where its tint of its base is. let (mut links, mut colors, mut gradients, mut patterns, mut gradient_links) = (vec![], vec![], vec![], vec![], vec![]); let mut color = |c: &Color, link: Option<&String>, tint: f32| match link { - Some(l) if d.swatch(l).is_some() => links.push((l.clone(), tint)), + Some(l) if by_name.contains_key(l.as_str()) => links.push((l.clone(), tint)), _ => colors.push(*c), }; let mut paint = |p: &Paint| match p { @@ -838,16 +924,22 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result { } } } + let gradient_links: HashSet<&String> = gradient_links.iter().collect(); + let link_names: HashSet<&str> = links.iter().map(|(l, _)| l.as_str()).collect(); + let link_tints: HashSet<(&str, u32)> = links.iter().filter_map(|(l, t)| Some((l.as_str(), float_key(*t)?))).collect(); + let colors: HashSet = colors.iter().filter_map(color_key).collect(); + let patterns: HashSet<&String> = patterns.iter().collect(); + let gradients: HashSet = gradients.iter().filter_map(gradient_key).collect(); let used = |w: &Swatch| { gradient_links.contains(&w.name) - || links.iter().any(|(l, t)| match w.tint_of() { - Some((base, tint)) => l == base && *t == tint, - None => *l == w.name, - }) + || match w.tint_of() { + Some((base, tint)) => float_key(tint).is_some_and(|t| link_tints.contains(&(base, t))), + None => link_names.contains(w.name.as_str()), + } || match &w.paint { Paint::None => true, - Paint::Solid { color, .. } => colors.contains(color), - Paint::Gradient(g) => gradients.contains(&g.gradient), + Paint::Solid { color, .. } => color_key(color).is_some_and(|k| colors.contains(&k)), + Paint::Gradient(g) => gradient_key(&g.gradient).is_some_and(|k| gradients.contains(&k)), Paint::Pattern { pattern, .. } => patterns.contains(pattern), } }; @@ -1017,8 +1109,11 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren } } } + let mut free = d.free_swatch_names(); + free.insert(&name); + swatches.iter().for_each(|w| free.insert(&w.name)); for c in colors.iter().skip(swatches.len()) { - let nm = unique_name(&color_name(*c), |n| n == name || d.swatch_name_taken(n) || swatches.iter().any(|w| w.name == n)); + let nm = free.take(&color_name(*c)); swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false }); } let g = &mut d.swatch_groups[gi]; diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 0c365919b..618ef2ee3 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -20,7 +20,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, S use vectorcraft_geom::{Affine, Point}; use super::brushsym::{natural_size, place_xf}; -use super::{EngineError, Result, Session, bad, id_param, number_or, ok, str_param}; +use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param}; /// The name of the undo step that enters symbol editing mode. const ENTER: &str = "Edit Symbol"; @@ -65,7 +65,7 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.pattern_edit.is_some() { return Err(bad(C, "finish editing the pattern first")); } - let given = id_param(p, "id").map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty())); + let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty())); let (name, xf, instance) = match given { Some(ids) => { let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::>>(); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index fba84acaf..29d1de28f 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1676,7 +1676,7 @@ pub(crate) fn type_ranges( at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>, ) -> Result> { // (text, its range; None: all of it). - let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") { + 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. Some(id) if s.doc()?.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Text(_))) => { return Err(bad(c, "no text objects selected")); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index c6442c193..17b2f0f0c 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -10,8 +10,9 @@ use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef}; +use super::clipboard::resources::import_text_styles; use super::typecmd::{refresh_bounds, text_targets}; -use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, id_param, index, ok, str_param}; +use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, index, object_param, ok, str_param}; pub const NORMAL_CHAR: &str = "[Normal Character Style]"; pub const NORMAL_PARA: &str = "[Normal Paragraph Style]"; @@ -111,10 +112,21 @@ macro_rules! style_cmds { |s, _| unused(s, $kind), false, ), + spec( + concat!($p, ".load"), + concat!("Load ", $what, " Styles…"), + &["Window", "Type", $menu], + LOAD_PARAMS, + |s, p| load(s, p, &[$kind]), + true, + ), ] }; } +/// 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"; + pub fn specs() -> Vec { let mut v = style_cmds!( Kind::Char, @@ -132,6 +144,7 @@ pub fn specs() -> Vec { true, )); v.extend(style_cmds!(Kind::Para, "paraStyle", "Paragraph Styles", "Paragraph", "")); + v.push(spec("textStyle.loadAll", "Load All Styles…", &["Window", "Type"], LOAD_PARAMS, |s, p| load(s, p, &[Kind::Char, Kind::Para]), true)); v } @@ -426,10 +439,11 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map Result>> { +/// Paragraph capture always samples the paragraph at `start`. An `id` that isn't an object id is +/// an error under the `verb` command of `kind`. +fn selection_attrs(s: &Session, p: &Value, kind: Kind, verb: &str) -> Result>> { let d = &s.doc()?.doc; - let id = match id_param(p, "id") { + let id = match object_param(p, "id", &kind.cmd(verb))? { Some(id) => Some(id), None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()), }; @@ -451,14 +465,14 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result Result>> { - selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char }) + selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char }, "new") } /// The attributes `charStyle.new` (`para`: `paraStyle.new`) would give a style made now from /// `target` (its `id`, `start` and `end`; empty for the selected objects). `None` without text /// there. pub fn target_style_attrs(s: &Session, target: &Value, para: bool) -> Option> { - selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }).ok().flatten() + selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }, "new").ok().flatten() } /// The attributes a new character or paragraph style receives when no text is selected. @@ -631,7 +645,7 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result { /// (`charStyle.duplicate`), which `attrs` keeps as [`attrs_param`] keeps the target text's. fn new_from(s: &mut Session, p: &Value, kind: Kind, copied: Option<&Map>) -> Result { let c = kind.cmd("new"); - let target = selection_attrs(s, p, kind)?; + let target = selection_attrs(s, p, kind, "new")?; let attrs = match attrs_param(&s.doc()?.doc, p, kind, "new", copied.or(target.as_ref()))? { Some(a) => a, None => match target { @@ -672,8 +686,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result { let style_name = (name != kind.normal()).then(|| name.clone()); // A Type tool range or whole text objects. let text_range = super::typecmd::TextRange::parse(p, &c)?; - let range = id_param(p, "id").filter(|_| text_range.is_some()); - let ids = match range.or_else(|| id_param(p, "id")) { + let given = object_param(p, "id", &c)?; + let range = given.filter(|_| text_range.is_some()); + let ids = match given { Some(id) => vec![id], None => text_targets(s, p, &c)?, }; @@ -754,7 +769,7 @@ fn redefine_to(s: &mut Session, kind: Kind, name: &str, new_attrs: Map Result { let name = name_param(p, kind, "redefine")?.to_string(); - let attrs = selection_attrs(s, p, kind)?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?; + let attrs = selection_attrs(s, p, kind, "redefine")?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?; redefine_to(s, kind, &name, attrs, "redefine") } @@ -899,5 +914,89 @@ fn unused(s: &mut Session, kind: Kind) -> Result { Ok(json!({ "names": names })) } +/// 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) +fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { + const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"]; + let (mut defs, mut skipped) = (vec![], vec![]); + for def in kind.defs(src) { + let name = def.name.as_str(); + if name.trim() == kind.normal() { + continue; + } + // Paints, features and language are taken as the file stores them; the ranged values are checked. + let mut kept = def.attrs.clone(); + kept.retain(|k, _| !RANGED.contains(&k.as_str())); + match attrs_param(src, &json!({ "attrs": def.attrs }), kind, "load", Some(&kept)).ok().flatten().filter(|_| !name.trim().is_empty()) { + Some(attrs) => defs.push(TextStyleDef { name: name.to_string(), attrs }), + None => skipped.push(def.name.clone()), + } + } + (defs, skipped) +} + +/// Load Character Styles, Load Paragraph Styles and Load All Styles: the `kinds` of style of the +/// document `p` gives (`path`, `data` or `dataBase64`) added to the active one with the swatches and +/// patterns they paint with, as Paste adds those of pasted type ([`import_text_styles`]). +fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result { + let label = match kinds { + [Kind::Char] => "Load Character Styles", + [Kind::Para] => "Load Paragraph Styles", + _ => "Load All Styles", + }; + let c = &match kinds { + [kind] => kind.cmd("load"), + _ => "textStyle.loadAll".to_string(), + }; + let path = str_param(p, "path"); + let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => super::fileio::read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(c, "bad dataBase64"))?, + _ => return Err(bad(c, "give `path`, `data` or `dataBase64`")), + }; + let file = str_param(p, "name").or(path).unwrap_or("Styles"); + let src = super::fileio::load(file, &bytes).map_err(|e| bad(c, e.to_string()))?; + if src.preview_only { + return Err(bad(c, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning")); + } + // The source with only the styles that load. Those of a kind not loaded still come along when + // a pattern or symbol that a loaded style paints with sets type in them. + let mut src = src.doc; + let mut skipped = [vec![], vec![]]; + for kind in [Kind::Char, Kind::Para] { + let (defs, names) = loadable(&src, kind); + *kind.defs_mut(&mut src) = defs; + if kinds.contains(&kind) { + skipped[usize::from(kind == Kind::Para)] = names; + } + } + let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para)); + let mut trial = (*s.doc()?.doc).clone(); + let mut placed = import_text_styles(&src, &mut trial, chars, paras); + // An undo step only when the document changes. The step takes the document the import made, + // unless the edit first ends a typing session, which can change the document it starts from. + if trial != *s.doc()?.doc { + if super::typing_in_progress(s) { + placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?; + } else { + s.edit(label, |d, _| { + *d = trial; + Ok(()) + })?; + } + } + let reply = |para: bool| { + let names = |added: bool| placed.iter().filter(|p| p.para == para && p.added == added).map(|p| p.name.as_str()).collect::>(); + json!({ "added": names(true), "existing": names(false), "skipped": skipped[usize::from(para)] }) + }; + Ok(match kinds { + [kind] => reply(*kind == Kind::Para), + _ => json!({ "charStyles": reply(false), "paraStyles": reply(true) }), + }) +} + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 7c0fd9963..73c938dc1 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -733,6 +733,480 @@ fn several_styles_are_deleted_in_one_step() { assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Style"); } +/// A saved document whose character and paragraph styles are `char` and `para` (name, attrs), +/// stored as they are, as `charStyle.load`'s `dataBase64`. +fn styles_file(char: &[(&str, Value)], para: &[(&str, Value)]) -> String { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + let defs = |v: &[(&str, Value)]| { + v.iter().map(|(n, a)| TextStyleDef { name: n.to_string(), attrs: a.as_object().cloned().unwrap_or_default() }).collect::>() + }; + d.char_styles = defs(char).into(); + d.para_styles = defs(para).into(); + vectorcraft_format::base64_encode(&vectorcraft_format::save(&d, false)) +} + +/// Load Character Styles and Load Paragraph Styles copy the other document's styles of their kind +/// after the document's own, in one undo step. A style with the same attributes as the style here +/// of its name, or of its name with a number, is not added again; any other gets the first free of +/// those names, and the style here and the type using it keep their attributes. The file's Normal +/// style is not loaded, and a style whose attributes do not read, or whose name is empty, is +/// skipped. +#[test] +fn styles_load_from_another_document_in_one_step() { + for p in ["charStyle", "paraStyle"] { + let (mut s, id) = session_with_text("Title"); + let normal = if p == "charStyle" { NORMAL_CHAR } else { NORMAL_PARA }; + let key = if p == "charStyle" { "size" } else { "space_after" }; + let size = |v: f64| Value::Object(Map::from_iter([(key.to_string(), json!(v))])); + for (n, v) in [("Body", 12.0), ("Accent", 9.0), ("Accent 2", 1.0)] { + s.execute(&format!("{p}.new"), &json!({"name": n, "attrs": size(v)})).unwrap(); + } + s.execute(&format!("{p}.apply"), &json!({"name": "Accent", "id": id})).unwrap(); + let file = + [("Body", size(12.0)), ("Accent", size(20.0)), ("Fresh", size(5.0)), (normal, size(30.0)), ("Bad", json!({"nope": 1})), (" ", size(2.0))]; + let data = styles_file(&file, &file); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(r, json!({"added": ["Accent 3", "Fresh"], "existing": ["Body"], "skipped": ["Bad", " "]}), "{p}"); + assert_eq!(listed(&mut s, p), [normal, "Body", "Accent", "Accent 2", "Accent 3", "Fresh"], "{p}"); + let d = s.doc().unwrap().doc.clone(); + let attrs = |name: &str| super::style_attrs(&d, p == "paraStyle", name).map(Value::Object); + assert_eq!((attrs("Accent"), attrs("Accent 3"), attrs(normal)), (Some(size(9.0)), Some(size(20.0)), Some(json!({}))), "{p}"); + // The other kind and the type are as they were. + let (other, mine) = if p == "charStyle" { (&d.para_styles, &d.char_styles) } else { (&d.char_styles, &d.para_styles) }; + let (other_before, mine_before) = + if p == "charStyle" { (&before.para_styles, &before.char_styles) } else { (&before.char_styles, &before.para_styles) }; + assert_eq!((other, &mine[..mine_before.len()]), (other_before, &mine_before[..]), "{p}"); + assert_eq!(d.node(NodeId(id)), before.node(NodeId(id)), "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + // Loading the same file again adds nothing and makes no undo step. + let again = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(again["added"], json!([]), "{p}"); + assert_eq!(again["existing"], json!(["Body", "Accent 3", "Fresh"]), "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + let undone = s.execute("edit.undo", &json!({})).unwrap()["undone"].clone(); + assert_eq!(undone, if p == "charStyle" { "Load Character Styles" } else { "Load Paragraph Styles" }); + assert_eq!(s.doc().unwrap().doc, before, "{p}"); + } +} + +/// Load All Styles loads both kinds in one undo step, from a path too; a file that does not open, +/// or no file, loads nothing. +#[test] +fn load_all_styles_loads_both_kinds_in_one_step() { + let (mut s, _) = session_with_text("Title"); + let data = styles_file(&[("Lead", json!({"size": 30.0}))], &[("Centered", json!({"justify": "Center"}))]); + let dir = std::env::temp_dir().join(format!("vc-load-styles-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Other.vectorcraft"); + std::fs::write(&path, vectorcraft_format::base64_decode(&data).unwrap()).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for bad in [json!({}), json!({"data": "not a document", "name": "x.vectorcraft"}), json!({"dataBase64": "!"})] { + assert!(s.execute("textStyle.loadAll", &bad).is_err(), "{bad}"); + } + let r = s.execute("textStyle.loadAll", &json!({"path": path.to_string_lossy()})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + let one = |name: &str| json!({"added": [name], "existing": [], "skipped": []}); + assert_eq!(r, json!({"charStyles": one("Lead"), "paraStyles": one("Centered")})); + assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Lead"]); + assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered"]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Load All Styles"); + assert_eq!(s.doc().unwrap().doc, before); +} + +/// A loaded character style whose size, scale, leading, shift or stroke width is out of the range +/// `charStyle.new` takes is skipped. +#[test] +fn loaded_styles_out_of_range_are_skipped() { + let (mut s, _) = session_with_text("Title"); + let file = [ + ("Huge", json!({"size": 2000.0})), + ("Flat", json!({"h_scale": 0.5})), + ("Shifted", json!({"baseline_shift": 5000.0})), + ("Thick", json!({"stroke_width": -2.0})), + ("Fine", json!({"size": 12.0, "h_scale": 50.0})), + ]; + let data = styles_file(&file, &[]); + let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick"]})); + assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Fine"]); +} + +/// A loaded style brings the pattern it paints with, renamed when the document has another pattern +/// of that name, as Paste brings it; a global color whose name the document gives another color +/// is the document's. +#[test] +fn loaded_styles_bring_the_patterns_and_swatches_they_paint_with() { + // A pattern "Dots" made from a `size` square, a global "Ink", and type painted with each. + let painted = |size: f64, ink: &str| { + let (mut s, id) = session_with_text("Dots"); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": size, "height": size})).unwrap()["id"].clone(); + s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": ink, "global": true})).unwrap(); + (s, id) + }; + let (mut src, id) = painted(10.0, "#3366cc"); + src.execute("select.set", &json!({"ids": [id]})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Ink", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Inked", "id": id})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, id) = painted(30.0, "#ff0000"); + let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(r["added"], json!(["Patterned", "Inked"])); + let d = s.doc().unwrap().doc.clone(); + let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string(); + assert!(d.pattern("Dots 2").is_some() && fill("Patterned").contains("\"Dots 2\""), "{}", fill("Patterned")); + assert!(fill("Inked").contains("\"Ink\"") && d.swatch("Ink 2").is_none(), "{}", fill("Inked")); + s.execute("charStyle.apply", &json!({"name": "Patterned", "id": id})).unwrap(); + assert!(matches!(&runs(&s, id)[0].style.fill, vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2")); +} + +/// A pattern whose type is set in a loaded style that was renamed on the way in ("Accent 2") is +/// the same pattern when the file is loaded again, and so is the style that paints with it, also +/// when "Accent" itself paints with another pattern. +#[test] +fn reloaded_patterns_follow_the_renamed_styles_their_type_uses() { + for patterned in [false, true] { + let (mut src, id) = session_with_text("Tile"); + if patterned { + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap(); + } + src.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "kerning": 40})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + if patterned { + // A solid fill over the style's, so the tile's type itself paints with no pattern. + src.execute("paint.setFill", &json!({"color": "#000000", "ids": [id]})).unwrap(); + } + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, _) = session_with_text("X"); + s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + s.execute("charStyle.load", &load).unwrap(); + let counts = |s: &Session| { + let d = &s.doc().unwrap().doc; + (d.patterns.len(), d.char_styles.iter().map(|c| c.name.clone()).collect::>()) + }; + let first = counts(&s); + assert!(first.1.contains(&"Accent 2".to_string()) && first.1.contains(&"Tiled".to_string()), "{patterned}: {first:?}"); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{patterned}: {r}"); + assert_eq!(counts(&s), first, "{patterned}"); + } +} + +/// A loaded style that paints with a pattern whose type is set in that same style still paints +/// with the pattern when the pattern comes in under a free name ("Dots 2"). The commands refuse to +/// make that loop, so the source is a document edited directly, as a file may hold it. +#[test] +fn a_style_and_the_pattern_that_uses_it_load_together() { + let (mut src, id) = session_with_text("Tile"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone(); + source.char_styles.retain(|s| s.name != "Painted"); + source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted; + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false)); + let fill = |s: &Session, name: &str| super::style_attrs(&s.doc().unwrap().doc, false, name).unwrap()["fill"].to_string(); + let source_fill = super::style_attrs(&source, false, "Accent").unwrap()["fill"].to_string(); + assert!(source_fill.contains("\"Dots\""), "{source_fill}"); + let (mut s, _) = session_with_text("X"); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + s.execute("charStyle.load", &load).unwrap(); + assert!(s.doc().unwrap().doc.pattern("Dots 2").is_some()); + assert!(fill(&s, "Accent").contains("\"Dots 2\""), "{}", fill(&s, "Accent")); + // Loaded again, the two are found under those names, and nothing is added. + let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), (doc, undo)); +} + +/// A style and the pattern it paints with, whose type is set in that style, are both added when +/// the document has a style of that name and attributes but another pattern of that name: the +/// copy of the style paints with the copy of the pattern, and a second load adds nothing. A loop the +/// document has is used under the names it has together. +#[test] +fn a_style_in_a_loop_is_not_matched_to_a_style_that_paints_with_another_pattern() { + let (mut src, id) = session_with_text("Tile"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone(); + source.char_styles.retain(|s| s.name != "Painted"); + source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted.clone(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false)); + let (mut s, _) = session_with_text("X"); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + doc.char_styles.push(TextStyleDef { name: "Accent".into(), attrs: painted }); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!(["Accent 2"]), "{r}"); + let d = s.doc().unwrap().doc.clone(); + let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string(); + assert!(fill("Accent").contains("\"Dots\"") && fill("Accent 2").contains("\"Dots 2\""), "{} {}", fill("Accent"), fill("Accent 2")); + let NodeKind::Text(text) = &d.pattern("Dots 2").unwrap().art[0].kind else { panic!("pattern art should be type") }; + assert_eq!(text.runs[0].style.style_name.as_deref(), Some("Accent 2")); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc, d); + // With the loop's style renamed "Accent 3" and an "Accent 2" listed before it that also paints + // with "Dots 2", the loop is still found as "Accent 3" and "Dots 2", the names it has together. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let at = doc.char_styles.iter().position(|c| c.name == "Accent 2").unwrap(); + doc.char_styles[at].name = "Accent 3".into(); + let copy = TextStyleDef { name: "Accent 2".into(), attrs: doc.char_styles[at].attrs.clone() }; + doc.char_styles.insert(at, copy); + let tile = doc.patterns.iter_mut().find(|p| p.name == "Dots 2").unwrap(); + let NodeKind::Text(text) = &mut std::sync::Arc::make_mut(&mut tile.art[0]).kind else { panic!("pattern art should be type") }; + text.runs[0].style.style_name = Some("Accent 3".into()); + let d = s.doc().unwrap().doc.clone(); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r, json!({"added": [], "existing": ["Accent 3"], "skipped": []})); + assert_eq!(s.doc().unwrap().doc, d); +} + +/// A brush in a loaded style's pattern whose art sets type in a style renamed on the way in +/// ("Accent 2") is the same brush when the file is loaded again. +#[test] +fn reloaded_brushes_follow_the_renamed_styles_their_art_uses() { + let (mut src, id) = session_with_text("Ink"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("brush.new", &json!({"type": "art", "name": "Lettering", "ids": [id]})).unwrap(); + let path = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 40, "height": 10})).unwrap()["id"].clone(); + src.execute("brush.apply", &json!({"name": "Lettering", "ids": [path]})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [path]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, _) = session_with_text("X"); + s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + let r = s.execute("charStyle.load", &load).unwrap(); + assert!(r["added"].as_array().unwrap().contains(&json!("Accent 2")), "{r}"); + let d = s.doc().unwrap().doc.clone(); + let lib = vectorcraft_brush::library(&d); + let brush = lib.iter().find(|b| b.name == "Lettering").unwrap(); + let mut styles = vec![]; + for a in brush.art() { + a.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + styles.extend(t.runs.iter().filter_map(|r| r.style.style_name.clone())); + } + }); + } + assert_eq!(styles, ["Accent 2"]); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc, d); +} + +/// A style name from a saved document stays the name used by type in imported pattern art. +#[test] +fn loaded_style_keeps_the_name_used_by_pattern_type() { + let (mut src, id) = session_with_text("Tile"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().name = " Accent ".into(); + let tile = source.patterns.iter_mut().find(|p| p.name == "Tiles").unwrap(); + let art = std::sync::Arc::make_mut(&mut tile.art[0]); + let NodeKind::Text(text) = &mut art.kind else { panic!("pattern art should be type") }; + text.runs[0].style.style_name = Some(" Accent ".into()); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false)); + let (mut dst, _) = session_with_text("X"); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + dst.execute("charStyle.load", &load).unwrap(); + let doc = &dst.doc().unwrap().doc; + assert!(doc.char_styles.iter().any(|s| s.name == " Accent ")); + let NodeKind::Text(text) = &doc.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") }; + assert_eq!(text.runs[0].style.style_name.as_deref(), Some(" Accent ")); +} + +/// Load Character Styles brings the paragraph style that type in a loaded style's pattern is set +/// in, renamed when the document has another style of that name, without listing it in the reply. +#[test] +fn loaded_pattern_type_keeps_its_paragraph_style() { + let (mut src, id) = session_with_text("Tile"); + src.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center"}})).unwrap(); + src.execute("paraStyle.apply", &json!({"name": "Centered", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, _) = session_with_text("X"); + s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Right"}})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r, json!({"added": ["Tiled"], "existing": [], "skipped": []})); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered", "Centered 2"]); + assert_eq!(super::style_attrs(&d, true, "Centered 2").map(Value::Object), Some(json!({"justify": "Center"}))); + let NodeKind::Text(text) = &d.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") }; + assert_eq!(text.para_styles().filter_map(|p| p.style_name.as_deref()).collect::>(), ["Centered 2"]); + // Loaded again, nothing is added. + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc, d); +} + +/// A pattern whose tile paints with itself is found under its renamed copy on a second load. +#[test] +fn self_referencing_pattern_is_not_added_again() { + let (mut src, id) = session_with_text("Patterned"); + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [tile]})).unwrap(); + let art = src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone(); + assert!(art.appearance.items.iter().any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots"))); + let mut source = (*src.doc().unwrap().doc).clone(); + source.patterns.iter_mut().find(|p| p.name == "Dots").unwrap().art = vec![std::sync::Arc::new(art)]; + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap(); + source.char_styles = src.doc().unwrap().doc.char_styles.clone(); + assert!(super::style_attrs(&source, false, "Patterned").unwrap()["fill"].to_string().contains("\"Dots\"")); + let (mut dst, _) = session_with_text("X"); + let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some()); + assert!( + dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0] + .appearance + .items + .iter() + .any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2")) + ); + let before = dst.doc().unwrap().doc.clone(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert!(again.iter().all(|s| !s.added)); + assert_eq!(dst.doc().unwrap().doc, before); +} + +/// Patterns whose tiles each paint with themselves are matched one loop at a time: nine of them, +/// each in the document under its name with other art and as the copy an earlier load added, +/// are found under those copies, and a second load adds nothing. +#[test] +fn separate_self_referencing_patterns_are_not_added_again() { + let (mut src, id) = session_with_text("Patterned"); + let mut tiles = vec![]; + for k in 0..9 { + let name = format!("Dots{k}"); + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": name, "edit": false, "ids": [square]})).unwrap(); + let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("paint.setFill", &json!({"swatch": name, "ids": [tile]})).unwrap(); + tiles.push((name.clone(), src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone())); + src.execute("paint.setFill", &json!({"swatch": name, "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": format!("Patterned{k}"), "id": id})).unwrap(); + } + let mut source = (*src.doc().unwrap().doc).clone(); + for (name, art) in tiles { + source.patterns.iter_mut().find(|p| p.name == name).unwrap().art = vec![std::sync::Arc::new(art)]; + } + let (mut dst, _) = session_with_text("X"); + for k in 0..9 { + let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); + } + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + super::super::clipboard::resources::import_text_styles(&source, target, true, true); + for k in 0..9 { + assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); + } + let before = dst.doc().unwrap().doc.clone(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert!(again.iter().all(|s| !s.added)); + assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); + assert_eq!(dst.doc().unwrap().doc, before); + // With one loop's pattern gone from the document, the others are still found. + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + target.patterns.retain(|p| !p.name.starts_with("Dots8")); + let left = target.patterns.len(); + let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1); + assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added)); +} + +/// Nine patterns whose tiles each paint with the next one, the last with the first, in the +/// document under their names with other art and as the copies an earlier load added, are found +/// under those copies, and a second load adds nothing. +#[test] +fn a_long_loop_of_patterns_is_not_added_again() { + let (mut src, id) = session_with_text("Patterned"); + for k in 0..9 { + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [square]})).unwrap(); + } + let mut tiles = vec![]; + for k in 0..9 { + let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("paint.setFill", &json!({"swatch": format!("Dots{}", (k + 1) % 9), "ids": [tile]})).unwrap(); + tiles.push(src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone()); + } + src.execute("paint.setFill", &json!({"swatch": "Dots0", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + for (k, art) in tiles.into_iter().enumerate() { + source.patterns.iter_mut().find(|p| p.name == format!("Dots{k}")).unwrap().art = vec![std::sync::Arc::new(art)]; + } + let (mut dst, _) = session_with_text("X"); + for k in 0..9 { + let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); + } + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + super::super::clipboard::resources::import_text_styles(&source, target, true, true); + for k in 0..9 { + assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); + } + let before = dst.doc().unwrap().doc.clone(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert!(again.iter().all(|s| !s.added)); + assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); + assert_eq!(dst.doc().unwrap().doc, before); +} + /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no /// mojikumi) are still attributes a style can set, and a style captured with one applies to /// text at the default, is duplicated and comes back out of a library. diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 519d282e8..4ad577651 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Create Outlines", ["Type"], Some("Cmd+Shift+O"), - "{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows → {ids}", + "{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}", has_selection, create_outlines ), @@ -298,6 +298,18 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { t.cached_baselines = lay.baselines(); } +/// [`refresh_bounds`] for a document that is opening: the first frame takes the layout +/// ([`vectorcraft_text::take_kept_layout`]) instead of laying the object out again. +pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { + t.normalize_paras(); + auto_height(t); + let stamp = vectorcraft_text::LayoutStamp::now(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + t.cached_bounds = Some(lay.bounds); + t.cached_baselines = lay.baselines(); + vectorcraft_text::keep_layout(t, stamp, lay); +} + /// The text-space bounds of area type's frame, when Auto Size can fit it: a rectangle (one /// subpath, as large as its bounds) of horizontal type in one row, with some height. fn auto_height_frame(t: &TextObject) -> Option { @@ -432,7 +444,7 @@ fn columns_hold(lines: &[vectorcraft_text::LineInfo], cols: usize, y0: f64, inse /// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough /// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off. pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) { - for id in text_ids(d, ids) { + for id in text_ids_where(d, ids, |t| t.cached_bounds.is_none()) { if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) && t.cached_bounds.is_none() { @@ -498,11 +510,22 @@ fn overflow_note(ids: &[NodeId], left_out: &str) -> Option { /// Text objects among `ids` and their descendants. fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { + text_ids_where(d, ids, |_| true) +} + +/// The text objects among `ids` and their descendants that `keep` accepts, each once, in the order +/// they are found. All of `ids` are looked up in one walk of the tree. +fn text_ids_where(d: &Document, ids: &[NodeId], keep: impl Fn(&TextObject) -> bool) -> Vec { + let paths = d.index_paths(ids.iter().copied()); + let mut seen = std::collections::HashSet::new(); let mut out = vec![]; for id in ids { - if let Some(n) = d.node(*id) { + if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) { n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) { + if let NodeKind::Text(t) = &c.kind + && keep(t) + && seen.insert(c.id) + { out.push(c.id); } }); @@ -554,6 +577,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { if texts.is_empty() { return Err(EngineError::Other("Create Outlines: select text objects".into())); } + let ended = super::dimension::end_links(d, &roots); let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); let mut out = vec![]; let threaded = super::threads::frames(d); @@ -648,6 +672,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { new_sel.push(gid); } } + super::dimension::restore_unchanged(d, ended); // Outlined frames of threaded text leave their threads with the text they show. super::threads::leave_threads(d, &threaded); sel.set(new_sel); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index f5d36a7b2..83dfd925d 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -6,9 +6,11 @@ use serde_json::{Value, json}; use vectorcraft_doc::{CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; use vectorcraft_geom::{Affine, Rect, shapes}; +use super::object::choice; use super::typecmd::refresh_bounds; use super::{ - CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, find_command, has_clipboard, has_doc, has_selection, ids_param, str_param, targets, + CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or, + switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -108,11 +110,13 @@ pub fn specs() -> Vec { /// Text objects among `ids` and their descendants. pub(crate) fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { + let paths = d.index_paths(ids.iter().copied()); + let mut seen = std::collections::HashSet::new(); let mut out = vec![]; for id in ids { - if let Some(n) = d.node(*id) { + if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) { n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) { + if matches!(c.kind, NodeKind::Text(_)) && seen.insert(c.id) { out.push(c.id); } }); @@ -180,7 +184,7 @@ struct CaseState { fn change_case(s: &mut Session, p: &Value) -> Result { const C: &str = "type.changeCase"; - let case = str_param(p, "case").ok_or_else(|| bad(C, "missing case"))?.to_ascii_lowercase(); + let case = text_param(p, "case", C)?.ok_or_else(|| bad(C, "missing case"))?.to_ascii_lowercase(); if !["upper", "uppercase", "lower", "lowercase", "title", "sentence"].contains(&case.as_str()) { return Err(bad(C, "case must be upper|lower|title|sentence")); } @@ -251,11 +255,14 @@ pub(crate) fn smarten(text: &str, prev: &mut Option, quotes: Option<&Quote } fn smart_punctuation(s: &mut Session, p: &Value) -> Result { - let (d, e) = (bool_or(p, "dashes", true), bool_or(p, "ellipsis", true)); + const C: &str = "type.smartPunctuation"; + let (d, e) = (switch_or(p, "dashes", true, C)?, switch_or(p, "ellipsis", true, C)?); + let quotes = switch_or(p, "quotes", true, C)?; + let document = choice(p, C, "scope", &["selection", "document"])? == Some("document"); let st = s.doc()?; // Quotes in the document's style (Document Setup → Type). - let q = bool_or(p, "quotes", true).then_some(st.doc.setup.quotes); - let ids = if str_param(p, "scope") == Some("document") || st.selection.is_empty() { + let q = quotes.then_some(st.doc.setup.quotes); + let ids = if document || st.selection.is_empty() { let mut v = vec![]; st.doc.walk(|n| { if matches!(n.kind, NodeKind::Text(_)) { @@ -434,16 +441,19 @@ fn path_options(s: &mut Session, p: &Value) -> Result { let start = fraction("start")?; // `end: null` puts the end bracket back at the end of the path. let end = p.get("end").map(|_| fraction("end")).transpose()?; - let flip = bool_or(p, "flip", false); - let effect = match str_param(p, "effect") { + let flip = switch_or(p, "flip", false, C)?; + let effect = match text_param(p, "effect", C)? { Some(e) => Some(vectorcraft_doc::PathEffect::parse(e).ok_or_else(|| bad(C, format!("unknown effect `{e}`")))?), None => None, }; - let align = match str_param(p, "alignToPath") { + let align = match text_param(p, "alignToPath", C)? { Some(a) => Some(vectorcraft_doc::PathAlign::parse(a).ok_or_else(|| bad(C, format!("unknown alignToPath `{a}`")))?), None => None, }; - let spacing = p.get("spacing").and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(-1000.0, 1000.0)); + let spacing = match p.get("spacing") { + None | Some(Value::Null) => None, + Some(_) => Some(number_or(p, "spacing", 0.0, C)?.clamp(-1000.0, 1000.0)), + }; // Without a flip, when every object already has every value given, the document and its undo // history stay as they are. let has_values = |t: &TextObject| match &t.kind { @@ -606,16 +616,21 @@ fn unicode_char(u: &str) -> Option { fn insert_char(s: &mut Session, p: &Value) -> Result { const C: &str = "type.insert"; - let text = match (str_param(p, "char"), str_param(p, "text")) { - (Some(c), _) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(), - (None, Some(t)) => t.to_string(), - _ => match (p.get("code"), str_param(p, "unicode")) { - (Some(code), _) => alt_code_char(code.as_u64().ok_or_else(|| bad(C, "`code` must be a whole number"))?) - .ok_or_else(|| bad(C, format!("no character has code {code}")))? - .to_string(), - (None, Some(u)) => unicode_char(u).ok_or_else(|| bad(C, format!("`{u}` is no Unicode character")))?.to_string(), - _ => return Err(bad(C, "give `char`, `text`, `code` or `unicode`")), - }, + // Every source is type-checked before one is chosen, so a bad value is refused even when a + // source ahead of it is given. + let ch = text_param(p, "char", C)?; + let txt = text_param(p, "text", C)?; + let code = match p.get("code").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_u64().ok_or_else(|| bad(C, "`code` must be a whole number"))?), + None => None, + }; + let uni = text_param(p, "unicode", C)?; + let text = match (ch, txt, code, uni) { + (Some(c), ..) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(), + (None, Some(t), ..) => t.to_string(), + (None, None, Some(code), _) => alt_code_char(code).ok_or_else(|| bad(C, format!("no character has code {code}")))?.to_string(), + (None, None, None, Some(u)) => unicode_char(u).ok_or_else(|| bad(C, format!("`{u}` is no Unicode character")))?.to_string(), + (None, None, None, None) => return Err(bad(C, "give `char`, `text`, `code` or `unicode`")), }; let ids = texts(s, p, C)?; // When `text` is empty and every object ends with a run of text, the document and its undo @@ -720,11 +735,11 @@ fn all_texts(d: &Document) -> Vec { fn find_replace(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.findReplace"; - let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); - let rep = str_param(p, "replace").unwrap_or("").to_string(); - let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false)); - let ids = match ids_param(p, "ids") { - Some(v) => text_ids(&s.doc()?.doc, &v), + let find = text_param(p, "find", C)?.filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); + let rep = text_param(p, "replace", C)?.unwrap_or("").to_string(); + let (mc, ww) = (switch_or(p, "matchCase", false, C)?, switch_or(p, "wholeWord", false, C)?); + let ids = match p.get("ids").filter(|v| !v.is_null()) { + Some(_) => text_ids(&s.doc()?.doc, &checked_ids_param(s, p, "ids", C)?), None => all_texts(&s.doc()?.doc), }; let st = s.doc()?; @@ -777,8 +792,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { fn find_next(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.findNext"; - let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); - let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false)); + let find = text_param(p, "find", C)?.filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); + let (mc, ww) = (switch_or(p, "matchCase", false, C)?, switch_or(p, "wholeWord", false, C)?); let st = s.doc()?; let all = all_texts(&st.doc); let cur = st.selection.objects.first().and_then(|id| all.iter().position(|x| x == id)); diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index bac865c71..998109f3d 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Move Artboard", [], None, - "{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with prefs moveLockedWithArtboard), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}", + "{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (default: prefs moveLockedWithArtboard)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with lockedAndHidden), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}", has_doc, artboard_move ), @@ -384,13 +384,14 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result { let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let move_art = bool_or(p, "moveArt", false); let copy = bool_or(p, "copy", false); + let locked_art = super::panelcmds::locked_and_hidden(s, p, move_art, "artboard.move")?; if !copy { super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?; } // Each artboard's art, an object counted once (artboards can overlap). let mut arts: Vec<(usize, Vec)> = vec![]; for &i in &indices { - let art = super::panelcmds::artboard_art(s, i, move_art, !copy)?; + let art = super::panelcmds::artboard_art(s, i, move_art, locked_art, !copy)?; let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect(); arts.push((i, art)); } diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 4752b0a96..a115498e1 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -27,8 +27,9 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo /// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the /// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol); /// an action leaves them out ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 21] = [ +const REPLAY_ONLY: [(&str, &str); 22] = [ ("file.new", "created"), + ("edit.newDocumentFromState", "created"), ("document.save", "modified"), ("file.saveAs", "modified"), ("edit.pasteInPlace", "artboard"), diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 5c3a9130d..0baab9654 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -102,6 +102,24 @@ pub struct DocState { /// paths the document links to are looked at within them later too, when the Links panel /// asks about a link outside any caller's scope. pub opened_by: Option>, + /// What [`DocState::any_empty_artboards`] last measured. Not saved. + empty_artboards: std::cell::RefCell, +} + +/// Whether a document has an artboard for Delete Empty Artboards to delete, with the document that +/// was measured. +#[derive(Clone, Debug, Default)] +struct EmptyArtboards { + /// The document measured. While this reference lasts, its allocation holds no other document, + /// and `Arc::make_mut` moves a document it changes to a new allocation. + doc: std::sync::Weak, + any: bool, +} + +#[cfg(test)] +thread_local! { + /// Tests: how many times [`DocState::any_empty_artboards`] measured a document on this thread. + pub(crate) static EMPTY_ARTBOARD_MEASURES: std::cell::Cell = const { std::cell::Cell::new(0) }; } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -280,8 +298,21 @@ impl DocState { import_losses: vec![], last_perspective: None, opened_by: file_access::current(), + empty_artboards: Default::default(), } } + /// `measure`'s answer for the document now. `measure` runs once for each version of the document, + /// so a menu that asks on every frame measures it once, and a change of the selection or the view + /// keeps the answer. + pub(crate) fn any_empty_artboards(&self, measure: impl FnOnce(&Document) -> bool) -> bool { + let mut last = self.empty_artboards.borrow_mut(); + if !std::ptr::eq(last.doc.as_ptr(), Arc::as_ptr(&self.doc)) { + #[cfg(test)] + EMPTY_ARTBOARD_MEASURES.with(|c| c.set(c.get() + 1)); + *last = EmptyArtboards { doc: Arc::downgrade(&self.doc), any: measure(&self.doc) }; + } + last.any + } /// Unsaved changes: the document differs from the saved one (selection changes and changes that /// are not edits to save don't count, [`DocState::same_content`]). pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index cb84e108e..86c46fcf2 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -155,6 +155,17 @@ impl History { self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum() } + /// The document at row `row` of the History panel, `now` being the document now: the rows before + /// it are the undo steps, oldest first, and the rows after it the redo steps, nearest first. + pub(crate) fn state<'a>(&'a self, now: &'a Arc, row: usize) -> Option<&'a Arc> { + let current = self.undo.len(); + match row.cmp(¤t) { + std::cmp::Ordering::Less => self.undo.get(row).map(|e| &e.doc), + std::cmp::Ordering::Equal => Some(now), + std::cmp::Ordering::Greater => self.redo.len().checked_sub(row - current).and_then(|i| self.redo.get(i)).map(|e| &e.doc), + } + } + /// Let the oldest steps go while over the step limit or the memory budget: undo steps first /// (the oldest), then redo steps (the furthest ahead). pub fn trim(&mut self) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7a77a1920..399a4ed11 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -70,6 +70,9 @@ pub struct Session { /// cursor asked for again over an unchanged document and selection measures nothing again. pub(crate) preview_memo: vectorcraft_tools::select::PreviewMemo, depth: u32, + /// Set while saved steps play ([`Session::replay`]): [`Session::execute`] then takes keys the + /// command's params description doesn't name instead of refusing them. + replaying: bool, /// Set when the active tool panicked (see [`guard`]); reported by the next tool event. tool_panic: Option, /// How many times switching or closing documents, or replacing the active one, has started the diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ce7c5e4a6..82946ac29 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -41,6 +41,12 @@ pub struct Prefs { /// Align panel menu › Align to Glyph Bounds › Area Text: the same for area type, instead of /// its frame. pub align_glyph_bounds_area: bool, + /// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active, the + /// canvas draws everything outside the artboards darker, the pasteboard and the art on it. + pub fade_region_outside_artboard: bool, + /// Artboard Options › Update While Dragging: the area outside the artboards stays darker while + /// the Artboard tool moves or resizes an artboard; off, it draws as usual until the drag ends. + pub artboard_update_while_dragging: bool, pub display_print_size: bool, pub double_click_to_isolate: bool, pub transform_pattern_tiles: bool, @@ -52,6 +58,9 @@ pub struct Prefs { pub scrub_numeric_fields: bool, /// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°. pub trackpad_rotate_view: bool, + /// A single-key tool shortcut held down chooses its tool until the key is let go, and then the + /// tool active before comes back; a quick press chooses the tool for good. + pub hold_tool_keys: bool, /// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this). pub paste_offset: f64, // Selection & Anchor Display @@ -115,6 +124,7 @@ pub struct Prefs { pub measurement_labels: bool, pub spacing_guides: bool, pub distance_guides: bool, + pub snap_to_last_location: bool, pub snapping_tolerance: f64, pub snap_tangent: bool, pub snap_perpendicular: bool, @@ -349,6 +359,8 @@ impl Default for Prefs { use_preview_bounds: false, align_glyph_bounds_point: false, align_glyph_bounds_area: false, + fade_region_outside_artboard: true, + artboard_update_while_dragging: true, display_print_size: false, double_click_to_isolate: true, transform_pattern_tiles: false, @@ -357,6 +369,7 @@ impl Default for Prefs { zoom_with_mouse_wheel: false, scrub_numeric_fields: true, trackpad_rotate_view: true, + hold_tool_keys: true, paste_offset: 10.0, selection_tolerance: 3.0, object_selection_by_path_only: false, @@ -410,6 +423,7 @@ impl Default for Prefs { measurement_labels: true, spacing_guides: true, distance_guides: true, + snap_to_last_location: false, snapping_tolerance: 4.0, snap_tangent: true, snap_perpendicular: true, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 41946c0eb..ec9fdbac1 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -25,6 +25,8 @@ impl Session { vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || { vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision() }); + // Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs. + vectorcraft_text::register_envelope_ink(); // Exports bake Vector Halftone into the dots the renderer samples. vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art); // OpenType SVG glyphs are drawn as the SVG importer reads their documents. @@ -44,6 +46,7 @@ impl Session { last_view: ViewInfo::default(), preview_memo: Default::default(), depth: 0, + replaying: false, tool_panic: None, tool_resets: 0, tool_set_since_reset: false, @@ -334,7 +337,7 @@ fn refresh_tree_text_bounds(n: &mut Arc) { if let NodeKind::Text(t) = &mut n.kind && t.cached_bounds.is_none() { - cmd::typecmd::refresh_bounds(t); + cmd::typecmd::refresh_bounds_for_first_frame(t); } if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { refresh_tree_text_bounds(&mut m.art); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index f993ac585..21822583d 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -35,10 +35,14 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?; - // The crate's own tests are the audit of the params descriptions (#1442): a key a test - // passes that its command's description doesn't name fails the test. - #[cfg(test)] - self.check_params(id, params)?; + // A key the command's params description doesn't name is a parameter error (#1442), before + // anything runs, for the commands other commands run too. A `command.batch` or an + // `action.batch` checks its steps before the first one runs, and each step is checked again + // as it runs, against the document the steps before it left (the effects `effect.setParams` + // edits). Saved steps played with `replay` aren't checked. + if !self.replaying { + self.check_params(id, params)?; + } // Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked // before anything else: a malformed one, or one naming no object, is an error under this // command's id, never the selection in its place. `effect.apply {id: "stylize.dropShadow"}` @@ -158,8 +162,8 @@ impl Session { } /// Record `key: value` in the running top-level command's journal entry (or its interaction's - /// preview, or its step in a batch) unless its params give `key`: a value it resolved from the - /// preferences or the clock. + /// preview, or its step in a batch) unless its params give `key` (a null gives nothing): a value + /// it resolved from the preferences or the clock. pub fn note_journal(&mut self, key: &str, value: Value) { if self.depth == self.note_depth { self.journal_note.insert(key.to_string(), value); @@ -186,6 +190,29 @@ impl Session { Ok((r?, noted)) } + /// [`Session::execute`] for saved steps: a journal entry or an action's steps, recorded by this + /// version or an older one. Keys a command's params description doesn't name (a key it no longer + /// reads, an effect parameter it no longer takes) are taken, by the commands it runs too, so what + /// was recorded still plays (#1442). Everything else is checked as `execute` checks it. + pub fn replay(&mut self, id: &str, params: &Value) -> Result { + let outer = self.set_replaying(true); + let r = self.execute(id, params); + self.set_replaying(outer); + r + } + + /// Whether [`Session::execute`] plays saved steps as [`Session::replay`] does. A frontend that + /// runs saved steps through its own path to `execute` sets it around that call, and puts back the + /// value this returns, which it had before. + pub fn set_replaying(&mut self, on: bool) -> bool { + std::mem::replace(&mut self.replaying, on) + } + + /// Whether saved steps are playing ([`Session::set_replaying`]). + pub fn replaying(&self) -> bool { + self.replaying + } + /// The journal from entry `start` on, as an action records it: without the params that only /// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now. pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> { @@ -204,9 +231,14 @@ impl Session { pub(super) fn noted(&mut self, params: &Value) -> Value { let mut note = std::mem::take(&mut self.journal_note); match params { - // The params' own values win. + // The params' own values win, except a null: it reads as a key left out, so the + // noted value is the one the command resolved. Value::Object(m) if !note.is_empty() => { - note.extend(m.clone()); + for (k, v) in m { + if !v.is_null() || !note.contains_key(k) { + note.insert(k.clone(), v.clone()); + } + } Value::Object(note) } Value::Null if !note.is_empty() => Value::Object(note), diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 0e85391fc..02f2bb532 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -14,11 +14,14 @@ //! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named //! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it. //! Attaching the folder installs them again. The Additional Plug-ins Folder preference is not -//! copied: it is loaded from where it is. +//! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins +//! stored there that list permissions (id, module hash, permissions); a stored plug-in whose +//! permissions it doesn't grant is skipped with a log record, and stays in the folder. //! //! The files are untrusted input like any other: sizes and counts are capped, a file that does not //! parse is skipped with a log record, and every write is atomic. +use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; @@ -41,6 +44,10 @@ const PROFILE_EXT: &str = "icc"; pub const MAX_PLUGIN_STORE: u64 = 512 << 20; /// The extension of a stored plug-in. const PLUGIN_EXT: &str = "wasm"; +/// The file in the plug-in store that holds the permissions the user granted. +const GRANTS_FILE: &str = "grants.json"; +/// The largest grants file read. +const MAX_GRANTS_FILE: u64 = 1 << 20; /// 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 @@ -217,8 +224,10 @@ impl Session { } /// Keep the plug-ins this session installs in `dir`, and install the ones already there (a - /// module that doesn't load is skipped with a log record). + /// module that doesn't load, and one that lists permissions the grants there don't grant it, + /// is skipped with a log record). pub fn attach_plugin_store(&mut self, dir: PathBuf) { + let grants = stored_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); let mut left = MAX_PLUGIN_STORE; for path in list_files(&dir, PLUGIN_EXT) { @@ -231,6 +240,18 @@ impl Session { vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string()) }); match loaded { + // Only this store's grants count: one given elsewhere in the process doesn't let a + // module stored here load. + Ok(plugin) if plugin.manifest().asks_permissions() => { + let m = plugin.manifest(); + match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) { + Some(g) => { + vectorcraft_plugins::registry::grant(g.clone()); + vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + } + None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()), + } + } Ok(plugin) => { vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } @@ -240,6 +261,26 @@ impl Session { self.plugin_dir = Some(dir); } + /// Whether `plugin` installs without asking the user: it lists no permissions, or its id was + /// granted every one it lists, in the grants of the attached plug-in store when there is one, + /// else in this process's ([`vectorcraft_plugins::registry::approved`]). + pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { + self.plugin_approver()(plugin) + } + + /// [`Session::plugin_approved`] for many plug-ins: reads the store's grants once, when it is + /// made, and checks each plug-in against them. + pub(crate) fn plugin_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> { + let grants = self.plugin_dir.as_deref().map(stored_grants); + move |plugin| { + let m = plugin.manifest(); + match &grants { + Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())), + _ => vectorcraft_plugins::registry::approved(plugin), + } + } + } + /// Write the module `bytes` of the plug-in `id` to the store. pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) { if let Some(dir) = &self.plugin_dir { @@ -247,6 +288,46 @@ impl Session { } } + /// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's + /// other grants as they are; with none left, remove the file. Entries this version can't use + /// are written back as they were, except one for `id`. A grants file that is too large, can't + /// 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`. + pub(crate) fn store_grant(&self, id: &str, grant: Option) { + let Some(dir) = &self.plugin_dir else { return }; + let mut file = match read_grants(dir) { + Ok(file) => file, + Err(e) => { + log::warn!("not updating the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display()); + return; + } + }; + let others = file.other.len(); + file.other.retain(|entry| entry.get("id").and_then(serde_json::Value::as_str) != Some(id)); + let changed = match grant { + Some(g) => file.grants.insert(id.to_string(), g.clone()).as_ref() != Some(&g), + None => file.grants.remove(id).is_some(), + } || file.other.len() != others; + if !changed { + return; + } + let mut entries: Vec = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect(); + entries.extend(file.other); + if entries.is_empty() { + take(dir, GRANTS_FILE); + return; + } + // The file is read back only up to its cap: written compactly when pretty-printed is larger, + // and not at all when that is too. + let fits = |bytes: &Vec| bytes.len() as u64 <= MAX_GRANTS_FILE; + match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) { + Some(bytes) => { + put(dir, GRANTS_FILE, &bytes); + } + None => log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)), + } + } + /// Remove the plug-in `id` from the store. pub(crate) fn unstore_plugin(&self, id: &str) { if let Some(dir) = &self.plugin_dir { @@ -255,6 +336,58 @@ impl Session { } } +/// What the grants file of a plug-in store holds. +#[derive(Default)] +struct GrantsFile { + /// The grants this version can use, by plug-in id. + grants: BTreeMap, + /// The entries it can't use (a permission it doesn't know, an invalid id), as they were. + other: Vec, +} + +/// The grants file of the plug-in store `dir`: empty when there is none, an error when it is too +/// large, can't be read or doesn't hold a list. An entry that doesn't parse as a grant, or names an +/// invalid id, is kept in [`GrantsFile::other`] with a log record. +fn read_grants(dir: &Path) -> Result { + let path = dir.join(GRANTS_FILE); + if !path.exists() { + return Ok(GrantsFile::default()); + } + let entries = + read_capped(&path, MAX_GRANTS_FILE).and_then(|bytes| serde_json::from_slice::>(&bytes).map_err(|e| e.to_string()))?; + let mut file = GrantsFile::default(); + let mut first_error = None; + for entry in entries { + let why = match serde_json::from_value::(entry.clone()) { + Ok(g) if vectorcraft_plugins::manifest::valid_id(&g.id) => { + file.grants.insert(g.id.clone(), g); + continue; + } + Ok(g) => format!("invalid id `{}`", g.id.escape_debug()), + Err(e) => e.to_string(), + }; + first_error.get_or_insert(why); + file.other.push(entry); + } + if let Some(why) = first_error { + log::warn!("skipping {} stored plug-in grants in {} (the first: {why})", file.other.len(), path.display()); + } + Ok(file) +} + +/// The grants kept in the plug-in store `dir` that this version can use, by plug-in id: none when +/// there is no grants file, and none, with a log record, when it is too large, can't be read or +/// doesn't hold a list. +fn stored_grants(dir: &Path) -> BTreeMap { + match read_grants(dir) { + Ok(file) => file.grants, + Err(e) => { + log::warn!("skipping the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display()); + BTreeMap::new() + } + } +} + /// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a /// built-in preset, at most [`MAX_STORE_FILES`]. fn load_presets(dir: &Path) -> Vec { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 862bc4b77..b9e247ed9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -70,6 +70,7 @@ mod gradpresets; mod gradspan; mod guide_clipboard; mod halftone; +mod history_document; mod history_mem; mod ids_without_selection; mod inline; @@ -3616,16 +3617,18 @@ fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() { } /// #785: commands whose `ids` or `id` the shared check doesn't read (it reads `id` where it stands -/// for `ids` of one) refuse one that isn't an object id. They don't act on the selection or the -/// current layer instead, and the document and its undo history stay as they are. +/// for `ids` of one) refuse one that isn't an object id. They don't act on the selection, the +/// current layer or a new object instead, and the document, the selection and the undo history +/// stay as they are. #[test] fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 80.0, 60.0); let b = rect(&mut s, 140.0, 120.0, 80.0, 60.0); let graph = s.execute("graph.create", &json!({"type": "column", "x": 400, "y": 300, "width": 120, "height": 80, "rows": [[1, 2]]})).unwrap(); + let text = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "type", "size": 30})).unwrap()["id"].clone(); let ids = [json!([a.0, "x"]), json!(3)]; - let id = [json!("2"), json!("$1.id"), json!(2.5)]; + let id = [json!("2"), json!("$1.id"), json!(2.5), json!("abc"), json!(true), json!({})]; let commands = [ ("object.blend.make", json!({}), "ids", &ids[..], json!([a.0, b.0])), ("object.envelope.makeWithWarp", json!({}), "ids", &ids[..], json!([a.0, b.0])), @@ -3635,6 +3638,26 @@ fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() { ("perspective.editText", json!({}), "id", &id[..], json!([a.0])), ("layer.clippingMask.toggle", json!({}), "id", &id[..], json!([a.0, b.0])), ("layer.newSublayer", json!({}), "parent", &id[..], json!([a.0])), + ("object.isolate", json!({}), "id", &id[..], json!([a.0])), + ("graphicStyle.new", json!({"name": "From a"}), "id", &id[..], json!([a.0])), + ("charStyle.new", json!({"name": "From type"}), "id", &id[..], json!([text])), + ("paraStyle.new", json!({"name": "From type"}), "id", &id[..], json!([text])), + ("charStyle.apply", json!({"name": "[Normal Character Style]"}), "id", &id[..], json!([text])), + ("type.step", json!({"attribute": "size"}), "id", &id[..], json!([text])), + ("layer.releaseToLayers", json!({"build": true}), "id", &id[..], json!([a.0])), + ("layer.locate", json!({}), "id", &id[..], json!([a.0])), + ("transparency.editOpacityMask", json!({}), "id", &id[..], json!([a.0])), + ("transparency.viewOpacityMask", json!({}), "id", &id[..], json!([a.0])), + ("links.info", json!({}), "id", &id[..], json!([a.0])), + ("links.unembed", json!({}), "id", &id[..], json!([a.0])), + ("links.goTo", json!({}), "id", &id[..], json!([a.0])), + ("shaper.release", json!({}), "id", &id[..], json!([a.0])), + ("shaper.expand", json!({}), "id", &id[..], json!([a.0])), + ("image.info", json!({}), "id", &id[..], json!([a.0])), + ("symbol.edit", json!({}), "id", &id[..], json!([a.0])), + ("object.blend.spine.addAnchor", json!({"x": 120, "y": 110}), "id", &id[..], json!([a.0])), + ("path.curvature", json!({"points": [[0, 0], [50, 40], [100, 0]]}), "id", &id[..], json!([a.0])), + ("css.export", json!({"unnamed": true}), "id", &id[..], json!([a.0])), ]; let mut wrong = vec![]; for (cmd, params, key, values, selected) in commands { @@ -3644,21 +3667,44 @@ fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() { if let Some(p) = p.as_object_mut() { p.insert(key.into(), bad.clone()); } - let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let (doc, selection, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), s.doc().unwrap().history.undo.len()); let r = s.execute(cmd, &p); - let refused = r.as_ref().err().map(|e| e.to_string()).is_some_and(|e| e.contains("object id") || e.contains("step reference")); - let same = s.doc().unwrap().doc == doc && s.doc().unwrap().history.undo.len() == undo; + let named = |e: &str| e.contains("object id") || e.contains("step reference") || e.contains(&format!("`{key}` must be")); + let refused = r.as_ref().err().map(|e| e.to_string()).is_some_and(|e| named(&e)); + let same = s.doc().unwrap().doc == doc && s.doc().unwrap().selection == selection && s.doc().unwrap().history.undo.len() == undo; if !(refused && same) { wrong.push(format!("{cmd} {p}: {r:?}, document unchanged: {same}")); } if !same { - s.execute("edit.undo", &json!({})).ok(); + let st = s.doc_mut().unwrap(); + (st.doc, st.selection) = (doc, selection); + st.history.undo.truncate(undo); } } } assert!(wrong.is_empty(), "{}", wrong.join("\n")); } +/// An omitted or null `id` still means the selection, and an object id names that object. +/// `effect.apply` with the effect given as `id` (its older key) still applies it to the selection, +/// as one undo step. +#[test] +fn an_omitted_id_still_means_the_selection_and_effect_apply_takes_an_effect_as_id() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 80.0, 60.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + for p in [json!({}), json!({"id": null}), json!({"id": a.0})] { + assert_eq!(s.execute("layer.locate", &p).unwrap()["id"], a.0, "{p}"); + } + for p in [json!({"unnamed": true}), json!({"id": null, "unnamed": true}), json!({"id": a.0, "unnamed": true})] { + assert_eq!(s.execute("css.export", &p).unwrap()["rules"], 1, "{p}"); + } + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("effect.apply", &json!({"id": "stylize.dropShadow"})).unwrap(); + assert_eq!(r["ids"], json!([a.0])); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); +} + #[test] fn malformed_anchor_selection_never_replaces_or_partially_changes_selection() { let mut s = session(); diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 17c6aac3c..1893f8c18 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -3,7 +3,7 @@ //! the box to the page again. use serde_json::{Value, json}; -use vectorcraft_doc::OrientedBox; +use vectorcraft_doc::{Node, OrientedBox}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::bbox::Handle; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -141,6 +141,149 @@ fn reset_bounding_box_squares_the_box_in_one_step() { assert!(near(angle(&s, a), 45.0)); } +/// The angles of every object in `n`'s subtree, opacity mask art included, layers left out. +fn subtree_angles(n: &Node, out: &mut Vec) { + if !n.is_layer() { + out.push(n.bbox_angle); + } + for c in n.children().into_iter().flatten() { + subtree_angles(c, out); + } + if let Some(m) = &n.extras().mask { + subtree_angles(&m.art, out); + } +} + +/// The angles of every object in the document, opacity mask art included, layers left out. +fn object_angles(s: &Session) -> Vec { + let mut out = vec![]; + for l in &s.doc().unwrap().doc.layers { + subtree_angles(l, &mut out); + } + out +} + +#[test] +fn reset_bounding_box_squares_the_objects_inside_a_group() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 300.0, 100.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.rotate", &json!({"angle": 10})).unwrap(); + assert!(near(angle(&s, g), 10.0) && near(angle(&s, a), 30.0) && near(angle(&s, b), 30.0)); + let n = undo_len(&s); + // The group and both members, in one step. + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 3); + assert_eq!(undo_len(&s), n + 1); + assert!(object_angles(&s).iter().all(|a| *a == 0.0), "{:?}", object_angles(&s)); + s.execute("object.ungroup", &json!({})).unwrap(); + assert_eq!(selection_box(&s).angle, 0.0); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0); + // Undo brings every angle back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(angle(&s, g), 10.0) && near(angle(&s, a), 30.0) && near(angle(&s, b), 30.0)); +} + +#[test] +fn reset_bounding_box_squares_the_turned_members_of_a_square_group() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 300.0, 100.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + assert_eq!(selection_box(&s).angle, 0.0); + let n = undo_len(&s); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 2); + assert_eq!(undo_len(&s), n + 1); + s.execute("object.ungroup", &json!({})).unwrap(); + assert_eq!(selection_box(&s).angle, 0.0); +} + +/// Two rectangles turned 20°, put in a container by `make`, turned 10° with it and reset, then +/// released by `release`: nothing stands at an angle before or after the release. +fn reset_then_release(make: &str, release: &str) -> Session { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 150.0, 120.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + s.execute(make, &json!({})).unwrap(); + s.execute("object.rotate", &json!({"angle": 10})).unwrap(); + assert!(object_angles(&s).iter().any(|a| near(*a, 30.0)), "{make}: {:?}", object_angles(&s)); + s.execute("object.resetBoundingBox", &json!({})).unwrap(); + assert!(object_angles(&s).iter().all(|a| *a == 0.0), "{make}: {:?}", object_angles(&s)); + s.execute(release, &json!({})).unwrap(); + assert!(object_angles(&s).iter().all(|a| *a == 0.0), "{release}: {:?}", object_angles(&s)); + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0, "{release}"); + s +} + +#[test] +fn reset_bounding_box_then_release_clipping_mask() { + reset_then_release("object.clippingMask.make", "object.clippingMask.release"); +} + +#[test] +fn reset_bounding_box_then_release_compound_path() { + reset_then_release("object.compoundPath.make", "object.compoundPath.release"); +} + +#[test] +fn reset_bounding_box_then_expand_blend() { + reset_then_release("object.blend.make", "object.blend.expand"); +} + +#[test] +fn reset_bounding_box_then_release_envelope() { + reset_then_release("object.envelope.makeWithWarp", "object.envelope.release"); +} + +#[test] +fn reset_bounding_box_then_release_repeat() { + reset_then_release("object.repeat.grid", "object.repeat.release"); +} + +#[test] +fn reset_bounding_box_then_release_intertwine() { + reset_then_release("object.intertwine.make", "object.intertwine.release"); +} + +#[test] +fn reset_bounding_box_then_release_linked_opacity_mask() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 150.0, 120.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let mask = s.doc().unwrap().doc.node(a).unwrap().extras().mask.clone().unwrap(); + assert!(mask.linked && near(mask.art.bbox_angle, 20.0)); + drop(s); + reset_then_release("transparency.makeOpacityMask", "transparency.releaseOpacityMask"); +} + +#[test] +fn reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 150.0, 120.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + let mask_angle = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().mask.as_ref().unwrap().art.bbox_angle; + assert!(near(angle(&s, a), 20.0) && near(mask_angle(&s), 20.0)); + // Only the object: its mask's art no longer turns with it. + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1); + assert_eq!(angle(&s, a), 0.0); + assert!(near(mask_angle(&s), 20.0)); +} + #[test] fn page_flips_keep_an_upright_box_upright() { let mut s = session(); diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index 4784831ef..638b7a1f4 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1343,6 +1343,150 @@ fn delete_duplicate_and_replace_symbols() { assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err()); } +fn xf_of(s: &Session, id: NodeId) -> vectorcraft_geom::Affine { + match node(s, id).kind { + NodeKind::SymbolInstance { xf, .. } => xf, + _ => panic!("{id:?} is not a symbol instance"), + } +} + +fn close_xf(a: vectorcraft_geom::Affine, b: vectorcraft_geom::Affine) -> bool { + a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| (x - y).abs() < 1e-9) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Reset Transformation gives rotated, scaled, sheared and reflected instances (in a group too) +/// their symbol's own scale again, each keeping the center of its bounding box, as one undo step. +#[test] +fn reset_transformation_restores_the_symbols_scale_and_keeps_the_center() { + let mut s = session(); + let (a, name, placed) = make_symbol(&mut s); + let b = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 300, "y": 200})).unwrap()["id"].as_u64().unwrap()); + let c = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 100, "y": 220})).unwrap()["id"].as_u64().unwrap()); + let master = xf_of(&s, b).with_translation(vectorcraft_geom::Vec2::ZERO); + select(&mut s, &[a]); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("object.scale", &json!({"sx": 150, "sy": 50})).unwrap(); + select(&mut s, &[b]); + s.execute("object.shear", &json!({"angle": 20})).unwrap(); + select(&mut s, &[c]); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + let turned: Vec<_> = [a, b, c].iter().map(|id| xf_of(&s, *id)).collect(); + let centers: Vec<_> = [a, b, c].iter().map(|id| bounds(&s, *id).center()).collect(); + let plain = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + select(&mut s, &[b, c]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[a, group, plain]); + let plain_before = node(&s, plain); + let before = undo_len(&s); + let r = s.execute("symbol.instance.resetTransform", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([a.0, b.0, c.0])); + assert_eq!(undo_len(&s), before + 1, "one undo step"); + for (id, center) in [a, b, c].into_iter().zip(¢ers) { + let got = bounds(&s, id); + assert!((got.width() - placed.width()).abs() < 1e-9 && (got.height() - placed.height()).abs() < 1e-9, "{id:?}: {got:?}"); + assert!((got.center() - *center).hypot() < 1e-9, "{id:?} keeps its center: {got:?} vs {center:?}"); + let xf = xf_of(&s, id); + assert!(close_xf(xf.with_translation(vectorcraft_geom::Vec2::ZERO), master), "{id:?}: {xf:?}"); + } + assert!(node(&s, plain) == plain_before, "other objects are left as they are"); + s.execute("edit.undo", &json!({})).unwrap(); + for (id, xf) in [a, b, c].into_iter().zip(turned) { + assert!(close_xf(xf_of(&s, id), xf), "Undo gives {id:?} its own transform back"); + } +} + +/// An instance of a symbol whose art lies away from the symbol's origin (as imported symbols can) +/// keeps the center of the box its art fills when Reset Transformation turns it back. +#[test] +fn reset_transformation_keeps_the_center_of_art_away_from_the_origin() { + let mut s = session(); + let r = rect(&mut s, 300.0, 10.0, 20.0, 20.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 id = d.alloc_id(); + let xf = vectorcraft_geom::Affine::translate((100.0, 100.0)) * vectorcraft_geom::Affine::rotate(0.5); + d.insert(Some(layer), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Off".into(), xf })).unwrap(); + let measured = |s: &Session| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + let before = measured(&s); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({"ids": [id.0]})).unwrap()["ids"], json!([id.0])); + let after = measured(&s); + assert!((after.center() - before.center()).hypot() < 1e-9, "{after:?} vs {before:?}"); + assert!((after.width() - 20.0).abs() < 1e-9 && (after.height() - 20.0).abs() < 1e-9, "{after:?}"); +} + +/// A stored natural size that is not positive (from a damaged file: zero or negative on either +/// side) does not flatten or mirror the instance: Reset Transformation gives it the art's own +/// size, as drawing the symbol does. +#[test] +fn reset_transformation_ignores_a_stored_size_that_is_not_positive() { + for size in [json!([0.0, 20.0]), json!([-20.0, 20.0]), json!([20.0, -5.0]), json!([-20.0, -20.0])] { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 20.0, 20.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: "Flat".into(), art: std::sync::Arc::new(art) }); + d.unknown.insert(vectorcraft_doc::SYMBOL_SIZES.to_string(), json!({ "Flat": size })); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let xf = vectorcraft_geom::Affine::translate((100.0, 100.0)) * vectorcraft_geom::Affine::rotate(0.5); + d.insert(Some(layer), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Flat".into(), xf })).unwrap(); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({"ids": [id.0]})).unwrap()["ids"], json!([id.0]), "{size}"); + let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert!((after.width() - 20.0).abs() < 1e-9 && (after.height() - 20.0).abs() < 1e-9, "{size}: {after:?}"); + assert!(close_xf(xf_of(&s, id).with_translation(vectorcraft_geom::Vec2::ZERO), vectorcraft_geom::Affine::IDENTITY), "{size}: not mirrored"); + } +} + +/// Reset Transformation adds no undo step for an instance already at its symbol's scale or for +/// one in perspective, and refuses an id naming no object and objects without an instance, +/// changing nothing. +#[test] +fn reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 1000, "height": 800})).unwrap(); + let (inst, name, _) = make_symbol(&mut s); + let before = undo_len(&s); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({"ids": [inst.0]})).unwrap()["ids"], json!([])); + assert_eq!(undo_len(&s), before, "nothing changed: no undo step"); + let plain = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + select(&mut s, &[inst]); + s.execute("object.rotate", &json!({"angle": 45})).unwrap(); + let turned = s.doc().unwrap().doc.clone(); + let before = undo_len(&s); + for bad in [json!({"ids": [inst.0, 999_999]}), json!({"id": 999_999}), json!({"ids": [plain.0]}), json!({"ids": "x"})] { + assert!(s.execute("symbol.instance.resetTransform", &bad).is_err(), "{bad}"); + } + assert!(*s.doc().unwrap().doc == *turned && undo_len(&s) == before, "the refusals change nothing"); + // An instance in perspective is left as it is. + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let projected = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 500, "y": 350})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[projected]); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("perspective.attach", &json!({"ids": [projected.0], "plane": "right"})).unwrap(); + let xf = xf_of(&s, projected); + let before = undo_len(&s); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({"ids": [projected.0]})).unwrap()["ids"], json!([])); + assert!(undo_len(&s) == before && close_xf(xf_of(&s, projected), xf), "an instance in perspective stays as it was"); + // Selected with a turned instance, the instance in perspective still stays as it was, and only + // the other one is reset and returned, in one undo step. + select(&mut s, &[inst, projected]); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({})).unwrap()["ids"], json!([inst.0])); + assert_eq!(undo_len(&s), before + 1, "one undo step"); + assert!(close_xf(xf_of(&s, projected), xf), "the instance in perspective stays as it was"); + let reset = xf_of(&s, inst).with_translation(vectorcraft_geom::Vec2::ZERO); + assert!(close_xf(reset, s.doc().unwrap().doc.symbol_natural_xf(&name)), "the turned instance is reset: {reset:?}"); +} + /// New Symbol and Duplicate Symbol trim the name they are given, as Symbol Options trims /// `newName`; the trimmed name is the one compared with the symbols there are. #[test] diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index cb6ca47c5..f841efedc 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -542,3 +542,112 @@ fn intents_reach_the_document_conversion() { assert_ne!(first(&a), first(&b)); let _ = Intent::Perceptual; } + +/// The error `cmd` gives for `params`: it must name `msg`. +fn refused(s: &mut Session, cmd: &str, params: Value, msg: &str) { + let e = match s.execute(cmd, ¶ms) { + Ok(r) => panic!("{cmd} {params} gave {r}"), + Err(e) => e.to_string(), + }; + assert!(e.contains(msg), "{cmd} {params}: {e}"); +} + +#[test] +fn color_settings_refuse_a_param_of_the_wrong_type() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let before = cms::active_settings(); + let rev = s.doc().unwrap().revision; + let c = "edit.colorSettings"; + refused(&mut s, c, json!({"bpc": "no"}), "`bpc` must be true or false"); + refused(&mut s, c, json!({"rgb": 5}), "`rgb` must be a string"); + refused(&mut s, c, json!({"cmyk": true}), "`cmyk` must be a string"); + refused(&mut s, c, json!({"intent": 2}), "`intent` must be a string"); + assert_eq!(cms::active_settings(), before, "the settings are unchanged"); + assert_eq!(s.doc().unwrap().revision, rev, "nothing redraws"); + // Omitted and null params keep the settings. + s.execute(c, &json!({"bpc": null, "rgb": null})).unwrap(); + assert_eq!(cms::active_settings(), before); + refused(&mut s, "color.convert", json!({"color": "#808080", "to": 1}), "`to` must be a string"); + refused(&mut s, "color.convert", json!({"color": "#808080", "intent": ["relative"]}), "`intent` must be a string"); + assert_eq!(s.execute("color.convert", &json!({"color": "#808080", "to": null})).unwrap()["model"], "rgb"); + let profiles = || cms::profiles().into_iter().map(|i| i.name).collect::>(); + let listed = profiles(); + refused(&mut s, "color.loadProfile", json!({"path": 7}), "`path` must be a string"); + refused(&mut s, "color.removeProfile", json!({"name": 7}), "`name` must be a string"); + assert_eq!(profiles(), listed, "no profile is loaded or removed"); + assert_eq!(cms::active_settings(), before, "the settings are unchanged"); + assert_eq!(s.doc().unwrap().revision, rev, "nothing redraws"); +} + +#[test] +fn document_color_mode_refuses_a_param_of_the_wrong_type() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#ff0000")); + let fill = fill_of(&s, a); + let undo = s.doc().unwrap().history.undo.len(); + for c in ["file.documentColorMode", "object.convertDocumentColorMode"] { + refused(&mut s, c, json!({"mode": "cmyk", "convert": "no"}), "`convert` must be true or false"); + refused(&mut s, c, json!({"mode": "rgb", "convert": 0}), "`convert` must be true or false"); + refused(&mut s, c, json!({"mode": "cmyk", "grays": 1}), "`grays` must be a string"); + refused(&mut s, c, json!({"mode": "cmyk", "intent": false}), "`intent` must be a string"); + } + let st = s.doc().unwrap(); + assert_eq!(st.doc.color_mode, ColorMode::Rgb); + assert_eq!(st.history.undo.len(), undo, "no undo step"); + assert_eq!(fill_of(&s, a), fill, "the colors are unchanged"); +} + +#[test] +fn proof_views_refuse_a_param_of_the_wrong_type() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let view = vectorcraft_render::proof::view(); + let rev = s.doc().unwrap().revision; + refused(&mut s, "view.proofSetup", json!({"simulatePaper": "yes"}), "`simulatePaper` must be true or false"); + refused(&mut s, "view.proofSetup", json!({"proof": 1}), "`proof` must be true or false"); + refused(&mut s, "view.proofSetup", json!({"target": 3}), "`target` must be a string"); + refused(&mut s, "view.proofColors", json!({"on": "true"}), "`on` must be true or false"); + refused(&mut s, "view.overprintPreview", json!({"on": 1}), "`on` must be true or false"); + refused(&mut s, "view.separationsPreview", json!({"on": "false"}), "`on` must be true or false"); + refused(&mut s, "view.separationsPreview", json!({"plates": "Cyan"}), "`plates` must be an array of these names: Cyan, Magenta"); + refused(&mut s, "view.separationsPreview", json!({"plates": ["Cyan", 2]}), "`plates` must be an array of these names"); + refused(&mut s, "view.separationsPreview", json!({"only": 0}), "`only` must be a string"); + refused(&mut s, "view.separationsPreview", json!({"toggle": ["Cyan"]}), "`toggle` must be a string"); + assert_eq!(vectorcraft_render::proof::view(), view, "the proof view is unchanged"); + assert_eq!(s.doc().unwrap().revision, rev, "nothing redraws"); + // A null `on` toggles, as an omitted one does. + let r = s.execute("view.proofColors", &json!({"on": null})).unwrap(); + assert_eq!(r["proofColors"], !view.proof_colors); + s.execute("view.proofColors", &json!({"on": view.proof_colors})).unwrap(); +} + +#[test] +fn set_spot_refuses_a_param_of_the_wrong_type() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 60, "y": 100, "k": 0}, "global": true})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + refused(&mut s, "swatch.setSpot", json!({"name": "Ink", "spot": "true"}), "`spot` must be true or false"); + refused(&mut s, "swatch.setSpot", json!({"name": 1}), "`name` must be a string"); + assert!(!s.doc().unwrap().doc.swatch("Ink").unwrap().spot, "the swatch stays a process color"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + s.execute("swatch.setSpot", &json!({"name": "Ink", "spot": null})).unwrap(); + assert!(s.doc().unwrap().doc.swatch("Ink").unwrap().spot, "a null `spot` is the default, true"); +} + +#[test] +fn gamut_check_refuses_colors_that_are_not_a_list() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#0000ff")); + s.execute("select.set", &json!({"ids": []})).unwrap(); + refused(&mut s, "color.gamutCheck", json!({"colors": "#00ff00"}), "`colors` must be an array of colors, not \"#00ff00\""); + refused(&mut s, "color.gamutCheck", json!({"colors": 5}), "`colors` must be an array of colors, not 5"); + refused(&mut s, "color.gamutCheck", json!({"colors": {"c": 100, "m": 0, "y": 0, "k": 0}}), "`colors` must be an array of colors"); + // A null list checks the document, as an omitted one does. + let r = s.execute("color.gamutCheck", &json!({"colors": null})).unwrap(); + assert_eq!(r, s.execute("color.gamutCheck", &json!({})).unwrap()); + assert!(r["outOfGamut"].as_array().unwrap().iter().any(|v| v["hex"] == "#0000ff"), "{r}"); +} diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index 8e3cbf6cc..e4d030d38 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -445,3 +445,470 @@ fn warped_type_in_a_symbol_reopens_unchanged() { let differ = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count(); assert_eq!(differ, 0, "{differ} pixels changed on reopening"); } + +/// The glyph outline bounds of type `id` in document space. +fn ink_of(s: &Session, id: NodeId) -> Rect { + match node(s, id).kind { + NodeKind::Text(t) => t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()), + k => panic!("{k:?}"), + } +} + +fn assert_box(a: Rect, b: Rect, what: &str) { + let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(d < 1e-6, "{what}: {a:?} vs {b:?}"); +} + +/// The box envelope `id` maps its content from. +fn src_of(s: &Session, id: NodeId) -> Rect { + live::EnvelopeMap::of(&node(s, id)).unwrap().src() +} + +/// Envelopes map point type, area type and type in a group from the glyph outline bounds, not +/// from the laid-out box (ascent to descent, or the area's frame): with a flat warp the distorted +/// type sits where the type was, and Make with Mesh lays its grid over the glyphs. +#[test] +fn envelopes_map_type_from_its_glyph_outline_bounds() { + let mut s = session(); + let point = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Envelope", "size": 48})).unwrap()); + let area = id_of( + &s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Area type", "size": 36, "area": {"width": 300, "height": 200}})).unwrap(), + ); + let grouped = id_of(&s.execute("text.create", &json!({"x": 450, "y": 200, "text": "Group", "size": 40})).unwrap()); + sel(&mut s, &[grouped]); + let group = id_of(&s.execute("object.group", &json!({})).unwrap()); + for (t, target) in [(point, point), (area, area), (grouped, group)] { + let (ink, laid) = (ink_of(&s, t), node(&s, t).geometric_bounds().unwrap()); + assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}"); + let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(node(&s, target))], Affine::IDENTITY).unwrap(); + assert_box(plain, ink, "geometric box"); + assert_box(stroked, ink, "box with strokes (none)"); + sel(&mut s, &[target]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), ink, "warp"); + assert_box(node(&s, e).geometric_bounds().unwrap(), ink, "flat warp's bounds"); + s.execute("edit.undo", &json!({})).unwrap(); + let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap()); + let EnvelopeKind::Mesh { points, .. } = kind(&s, e) else { panic!("mesh") }; + let grid = points.iter().fold(Rect::from_points(points[0], points[0]), |r, p| r.union_pt(*p)); + assert_box(grid, ink, "mesh grid"); + assert_box(src_of(&s, e), ink, "mesh"); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + +/// Shrink Text to Fit lays type out by its size on the page: after the envelope is scaled, it +/// maps from the glyphs of the type laid out at its new size, not those measured before. +#[test] +fn a_scaled_envelope_of_shrunk_type_maps_from_its_new_glyphs() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 2, "area": {"width": 3, "height": 3}})).unwrap()); + s.edit("shrink", |d, _| { + if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind { + tx.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 }; + } + Ok(()) + }) + .unwrap(); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + let text = |s: &Session| match node(s, e).kind { + NodeKind::Envelope { content, .. } => match &content[0].kind { + NodeKind::Text(tx) => tx.clone(), + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + let ink = |tx: &vectorcraft_doc::TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).ink_bounds().unwrap(); + let before = text(&s); + assert_box(src_of(&s, e), before.xf.transform_rect_bbox(ink(&before)), "before scaling"); + s.execute("object.scale", &json!({"sx": 150, "sy": 150, "origin": [100, 100]})).unwrap(); + let after = text(&s); + let fresh = after.xf.transform_rect_bbox(ink(&after)); + let stale = after.xf.transform_rect_bbox(ink(&before)); + assert!((fresh.width() - stale.width()).abs() > 1e-3 || (fresh.height() - stale.height()).abs() > 1e-3, "{fresh:?} vs {stale:?}"); + assert_box(src_of(&s, e), fresh, "after scaling"); +} + +/// A rotated envelope keeps a frame of its own: shrunk type in it is measured as it is laid out on +/// the page, and its box is then taken into the frame. +#[test] +fn a_rotated_envelope_of_shrunk_type_maps_from_the_glyphs_on_the_page() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 2, "area": {"width": 3, "height": 3}})).unwrap()); + s.edit("shrink", |d, _| { + if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind { + tx.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 }; + } + Ok(()) + }) + .unwrap(); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + s.execute("object.rotate", &json!({"angle": 30, "origin": [100, 100]})).unwrap(); + s.execute("object.scale", &json!({"sx": 150, "sy": 150, "origin": [100, 100]})).unwrap(); + let (frame, tx) = match node(&s, e).kind { + NodeKind::Envelope { frame, content, .. } => match &content[0].kind { + NodeKind::Text(tx) => (*frame, tx.clone()), + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + assert!(frame != Affine::IDENTITY, "a rotated envelope has a frame"); + let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap(); + assert_box(src_of(&s, e), (frame.inverse() * tx.xf).transform_rect_bbox(ink), "in the frame"); +} + +/// The bars of underlined type are part of what it draws, so the envelope maps from them too and +/// its bounds take them in. +#[test] +fn an_envelope_maps_underlined_type_from_its_glyphs_and_bars() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "HH", "size": 100})).unwrap()); + s.execute("text.setFormat", &json!({"ids": [t.0], "underline": true})).unwrap(); + let glyphs = ink_of(&s, t); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + let src = src_of(&s, e); + assert!(src.y1 > glyphs.y1 + 1.0, "the underline below the glyphs {glyphs:?} is in the box {src:?}"); + assert_box(node(&s, e).geometric_bounds().unwrap(), src, "flat warp's bounds"); + let drawn = vectorcraft_render::effects::expanded_live_group(Some(&s.doc().unwrap().doc), &node(&s, e)).geometric_bounds().unwrap(); + let off = [drawn.x0 - src.x0, drawn.y0 - src.y0, drawn.x1 - src.x1, drawn.y1 - src.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(off < 0.05, "the drawn art {drawn:?} fills the box {src:?}"); +} + +/// Undo of Make Envelope gives the type back with its laid-out box, and redo maps it from its +/// glyphs again. +#[test] +fn undo_and_redo_of_an_envelope_of_type_keep_the_glyph_mapping() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Undo", "size": 60})).unwrap()); + let (ink, laid) = (ink_of(&s, t), node(&s, t).geometric_bounds().unwrap()); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 30})).unwrap()); + let made = node(&s, e).geometric_bounds().unwrap(); + assert_box(src_of(&s, e), ink, "made"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(e).is_none()); + assert_box(node(&s, t).geometric_bounds().unwrap(), laid, "the type after undo"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_box(src_of(&s, e), ink, "redone"); + assert_box(node(&s, e).geometric_bounds().unwrap(), made, "redone bounds"); +} + +/// A saved envelope of type reopens drawn by the same rule: from the glyph outline bounds, which +/// the file does not store. +#[test] +fn a_saved_envelope_of_type_reopens_mapped_from_its_glyphs() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Saved", "size": 72})).unwrap()); + let ink = ink_of(&s, t); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "flag", "bend": 40})).unwrap()); + let render = |s: &Session| vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 600, 400, Affine::IDENTITY, &Default::default()); + let before = render(&s); + let dir = std::env::temp_dir().join(format!("vectorcraft-envelope-ink-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("type.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + let mut o = Session::new(); + o.execute("document.open", &json!({"path": path})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert_box(src_of(&o, e), ink, "reopened"); + let after = render(&o); + let differ = (0..400).flat_map(|y| (0..600).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count(); + assert_eq!(differ, 0, "{differ} pixels changed on reopening"); +} + +/// The type of envelope `e`'s first content object. +fn content_text(s: &Session, e: NodeId) -> vectorcraft_doc::TextObject { + match node(s, e).kind { + NodeKind::Envelope { content, .. } => match &content[0].kind { + NodeKind::Text(tx) => (**tx).clone(), + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + } +} + +/// The box of `tx`'s glyph outlines with text space mapped by `m`, each outline mapped point by point. +fn outlines_in(tx: &vectorcraft_doc::TextObject, m: Affine) -> Rect { + let path = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).to_bezpath(); + kurbo::Shape::bounding_box(&(m * path)) +} + +/// Rotated type maps from the box of its rotated glyph outlines, which is smaller than the box of +/// its rotated glyph box; in a rotated envelope whose frame turns by another angle than the type, +/// it maps from the outlines taken into the frame. +#[test] +fn an_envelope_of_rotated_type_maps_from_its_rotated_outlines() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 200, "y": 300, "text": "Rotated", "size": 60})).unwrap()); + sel(&mut s, &[t]); + s.execute("object.rotate", &json!({"angle": 40, "origin": [200, 300]})).unwrap(); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + let tx = content_text(&s, e); + let outlines = outlines_in(&tx, tx.xf); + let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap(); + let glyph_box = tx.xf.transform_rect_bbox(ink); + assert!(glyph_box.width() - outlines.width() > 1.0 || glyph_box.height() - outlines.height() > 1.0, "{glyph_box:?} vs {outlines:?}"); + assert_box(src_of(&s, e), outlines, "rotated type"); + s.execute("object.rotate", &json!({"angle": 15, "origin": [200, 300]})).unwrap(); + let (frame, tx) = match node(&s, e).kind { + NodeKind::Envelope { frame, .. } => (*frame, content_text(&s, e)), + k => panic!("{k:?}"), + }; + assert!(frame != Affine::IDENTITY, "a rotated envelope has a frame"); + let in_frame = frame.inverse() * tx.xf; + assert!(in_frame.as_coeffs()[1].abs() > 0.1, "the type is turned against the frame: {in_frame:?}"); + assert_box(src_of(&s, e), outlines_in(&tx, in_frame), "in the frame"); +} + +/// Type in perspective maps from the box of its outlines as its projection draws them, which is +/// smaller than the projected box of its glyphs. Where the projection can't map a point (past its +/// horizon), the point counts where it is, as the renderer draws it. +#[test] +fn an_envelope_of_type_in_perspective_maps_from_its_projected_outlines() { + use vectorcraft_geom::Homography; + // Every point of the type is past this one's horizon. + let horizon = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]]); + for (h, crosses) in [(Homography([[1.0, 0.3, 0.0], [0.1, 1.0, 0.0], [0.0015, 0.0008, 1.0]]), false), (horizon, true)] { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Perspective", "size": 60})).unwrap()); + s.edit("perspective", |d, _| { + let n = d.node_mut(t).unwrap(); + n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment { + projection: Some(h.to_array()), + ..vectorcraft_doc::PerspectiveAttachment::new("left", 0.0) + })); + Ok(()) + }) + .unwrap(); + assert!(node(&s, t).projection().is_some()); + let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") }; + let path = tx.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).to_bezpath(); + let (mut drawn, mut unmapped): (Option, usize) = (None, 0); + kurbo::flatten(&path, 0.001, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + let q = h.apply(p).unwrap_or_else(|| { + unmapped += 1; + p + }); + drawn = Some(drawn.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + }); + let drawn = drawn.unwrap(); + assert_eq!(unmapped > 0, crosses, "{unmapped} points past the horizon"); + if !crosses { + let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap(); + let boxed = h.map_rect_bbox(tx.xf.transform_rect_bbox(ink)).unwrap(); + assert!(boxed.width() - drawn.width() > 1.0 || boxed.height() - drawn.height() > 1.0, "{boxed:?} vs {drawn:?}"); + } + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + let src = src_of(&s, e); + let off = [src.x0 - drawn.x0, src.y0 - drawn.y0, src.x1 - drawn.x1, src.y1 - drawn.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(off < 0.05, "{src:?} vs {drawn:?}"); + } +} + +/// Type that draws nothing (only spaces) keeps its laid-out box. +#[test] +fn an_envelope_of_type_that_draws_nothing_maps_from_its_laid_out_box() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": " ", "size": 48})).unwrap()); + let laid = node(&s, t).geometric_bounds().unwrap(); + assert_box(live::envelope_content_bounds(&[std::sync::Arc::new(node(&s, t))]).unwrap(), laid, "spaces"); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), laid, "warp"); +} + +/// The strikethrough bar runs to the last glyph's advance, past its outline: the envelope maps +/// from it too. +#[test] +fn an_envelope_maps_struck_through_type_from_its_glyphs_and_bars() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "HH", "size": 100})).unwrap()); + let plain = ink_of(&s, t); + s.execute("text.setFormat", &json!({"ids": [t.0], "strikethrough": true})).unwrap(); + let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") }; + let db = vectorcraft_text::FontDb::global(); + let laid = vectorcraft_text::layout(db, &tx); + let bars = vectorcraft_text::decorations(&laid, db, &tx); + assert!(!bars.is_empty(), "struck-through type has a bar"); + let bar = tx.xf.transform_rect_bbox(kurbo::Shape::bounding_box(&bars[0].1)); + assert!(bar.x1 > plain.x1 + 1.0, "the bar {bar:?} reaches past the glyphs {plain:?}"); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), plain.union(bar), "glyphs and bar"); +} + +/// In a layer, the visible type is measured by its glyphs and hidden type isn't measured; in a +/// clip group whose clipping path is type, the envelope maps from that type's glyphs. +#[test] +fn type_in_layers_and_clip_groups_is_measured_by_its_glyphs() { + let mut s = session(); + let shown = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Shown", "size": 48})).unwrap()); + let hidden = id_of(&s.execute("text.create", &json!({"x": 100, "y": 500, "text": "Hidden", "size": 48})).unwrap()); + sel(&mut s, &[hidden]); + s.execute("object.hide", &json!({})).unwrap(); + let ink = ink_of(&s, shown); + let layer = s.doc().unwrap().doc.layers[0].clone(); + assert_box(live::envelope_content_bounds(&[layer]).unwrap(), ink, "layer"); + + let mut s = session(); + let back = rect(&mut s, 50.0, 100.0, 500.0, 200.0); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Clip", "size": 72})).unwrap()); + let ink = ink_of(&s, t); + sel(&mut s, &[back, t]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + assert!(matches!(node(&s, g).kind, NodeKind::Group { clip: true, .. })); + let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(node(&s, g))], Affine::IDENTITY).unwrap(); + assert_box(plain, ink, "clip group"); + assert_box(stroked, ink, "clip group with strokes (none)"); + sel(&mut s, &[g]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), ink, "warp of the clip group"); +} + +/// Stroked type: the box with strokes reaches past the glyphs as far as the type's strokes reach +/// past its laid-out box. In perspective, the glyphs are the projected outlines and the reach is +/// that of the strokes past the projected laid-out box. +#[test] +fn stroked_type_maps_from_its_glyphs_grown_by_its_strokes() { + let h = vectorcraft_geom::Homography([[1.0, 0.3, 0.0], [0.1, 1.0, 0.0], [0.0015, 0.0008, 1.0]]); + for perspective in [false, true] { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Stroke", "size": 60})).unwrap()); + sel(&mut s, &[t]); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 6})).unwrap(); + if perspective { + s.edit("perspective", |d, _| { + d.node_mut(t).unwrap().extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment { + projection: Some(h.to_array()), + ..vectorcraft_doc::PerspectiveAttachment::new("left", 0.0) + })); + Ok(()) + }) + .unwrap(); + } + let n = node(&s, t); + assert_eq!(n.projection().is_some(), perspective); + let (v, g) = (n.visual_bounds().unwrap(), n.geometric_bounds().unwrap()); + assert!(v.x0 < g.x0 - 1.0, "the strokes {v:?} reach past the laid-out box {g:?}"); + let NodeKind::Text(tx) = &n.kind else { panic!("type") }; + // The glyph outlines as drawn. + let ink = if perspective { + let path = tx.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).to_bezpath(); + let mut drawn: Option = None; + kurbo::flatten(&path, 0.001, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + let q = h.apply(p).unwrap(); + drawn = Some(drawn.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + }); + drawn.unwrap() + } else { + ink_of(&s, t) + }; + let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(n.clone())], Affine::IDENTITY).unwrap(); + let near = |a: Rect, b: Rect, what: &str| { + let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(d < 0.05, "{what} (perspective {perspective}): {a:?} vs {b:?}"); + }; + near(plain, ink, "geometric box"); + near(stroked, Rect::new(ink.x0 - (g.x0 - v.x0), ink.y0 - (g.y0 - v.y0), ink.x1 + (v.x1 - g.x1), ink.y1 + (v.y1 - g.y1)), "with strokes"); + } +} + +/// Make with Top Object maps type from its glyphs onto the path's outline. +#[test] +fn a_top_object_envelope_of_type_maps_from_its_glyphs() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Top", "size": 72})).unwrap()); + let ink = ink_of(&s, t); + let top = rect(&mut s, 80.0, 100.0, 300.0, 150.0); + sel(&mut s, &[t, top]); + let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap()); + assert!(matches!(kind(&s, e), EnvelopeKind::TopObject { .. })); + assert_box(src_of(&s, e), ink, "top object"); +} + +/// Type in a symbol instance is measured as before: the envelope maps from the instance's own +/// bounds, not from the type's glyphs. +#[test] +fn an_envelope_of_a_symbol_instance_of_type_maps_from_the_instance_s_bounds() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Symbol", "size": 60})).unwrap()); + let ink = ink_of(&s, t); + sel(&mut s, &[t]); + s.execute("symbol.new", &json!({"name": "Type"})).unwrap(); + let inst = s.doc().unwrap().selection.objects[0]; + assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. })); + let own = node(&s, inst).geometric_bounds().unwrap(); + assert!((own.height() - ink.height()).abs() > 1.0, "{own:?} vs {ink:?}"); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), own, "symbol instance"); +} + +/// Type inside a blend or a repeat is measured as before: the envelope maps from the object's own +/// bounds, not from the type's glyphs. +#[test] +fn an_envelope_of_a_blend_or_repeat_of_type_maps_from_its_own_bounds() { + for repeat in [false, true] { + let mut s = session(); + let a = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Blend", "size": 60})).unwrap()); + let mut parts = vec![a]; + if !repeat { + parts.push(id_of(&s.execute("text.create", &json!({"x": 300, "y": 400, "text": "Blend", "size": 60})).unwrap())); + } + let ink = parts.iter().map(|t| ink_of(&s, *t)).reduce(|a, b| a.union(b)).unwrap(); + sel(&mut s, &parts); + let made = if repeat { "object.repeat.grid" } else { "object.blend.make" }; + let holder = id_of(&s.execute(made, &json!({})).unwrap()); + let own = node(&s, holder).geometric_bounds().unwrap(); + let measured = live::envelope_content_bounds(&[std::sync::Arc::new(node(&s, holder))]).unwrap(); + assert_box(measured, own, made); + assert!((own.height() - ink.height()).abs() > 1.0 || (own.width() - ink.width()).abs() > 1.0, "{made}: {own:?} vs {ink:?}"); + sel(&mut s, &[holder]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), own, made); + } +} + +/// An inline graphic counts by its box: the envelope maps from the glyph outlines and the box of +/// the graphic, which reaches past the glyphs. +#[test] +fn an_envelope_maps_type_with_an_inline_graphic_from_its_glyphs_and_the_graphic() { + let mut s = session(); + s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": 40, "height": 40})).unwrap(); + s.execute("symbol.new", &json!({"name": "Dot"})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "xx", "size": 40})).unwrap()); + s.execute("text.insertInline", &json!({"id": t.0, "at": 1, "symbol": "Dot"})).unwrap(); + let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") }; + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx); + let [inline] = laid.inlines.as_slice() else { panic!("one inline graphic: {}", laid.inlines.len()) }; + let graphic = tx.xf.transform_rect_bbox(inline.bounds); + let glyphs = outlines_in(&tx, tx.xf); + assert!(graphic.y0 < glyphs.y0 - 1.0, "the graphic {graphic:?} reaches above the glyphs {glyphs:?}"); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), glyphs.union(graphic), "glyphs and graphic"); +} + +/// Reset with Mesh without Maintain Shape lays its flat grid over the glyphs. +#[test] +fn reset_with_mesh_lays_a_flat_grid_over_the_glyphs() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Reset", "size": 72})).unwrap()); + let ink = ink_of(&s, t); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 40})).unwrap()); + s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 3, "maintainShape": false})).unwrap(); + let EnvelopeKind::Mesh { points, .. } = kind(&s, e) else { panic!("mesh") }; + let grid = points.iter().fold(Rect::from_points(points[0], points[0]), |r, p| r.union_pt(*p)); + assert_box(grid, ink, "flat grid"); +} diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index d4671c11e..2e08b99c8 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -272,6 +272,62 @@ fn balance_trades_vector_regions_for_an_image() { assert_eq!(flatten(&mut s, json!({"ids": [a, b], "balance": 5})).2, 1); } +/// The balance's region limit, `10^(balance/20)` rounded down, is the most regions a group keeps. +#[test] +fn the_region_limit_is_the_most_regions_the_balance_keeps() { + use crate::cmd::FlattenOptions; + for balance in [0.0, 1.0, 5.0, 16.0, 17.0, 20.0, 40.0, 60.0, 75.0, 99.0, 99.99, 100.0] { + for clip in [false, true] { + let o = FlattenOptions::from_params(&json!({"balance": balance, "clipComplexRegions": clip})).unwrap(); + match o.region_limit() { + Some(n) => { + assert!(!o.rasterize_all(n) && o.rasterize_all(n + 1), "balance {balance}: limit {n}"); + assert!(!clip, "balance {balance}: a limit with Clip Complex Regions"); + } + None => assert!(clip || balance <= 0.0 || balance >= 100.0, "balance {balance}: no limit"), + } + } + } +} + +/// A row of four half-transparent rectangles splits into 7 regions. Under the balance's region +/// limit (10), at it (7), just over it (6) and far over it (1), Flatten Transparency, the flattened +/// document and the EPS file come out as they do when every region is built. +#[test] +fn groups_over_the_region_limit_flatten_as_with_every_region_built() { + use crate::cmd::FlattenOptions; + use crate::cmd::flatten::{flatten_document, with_every_region as every}; + let _settings = settings_lock(); + let setup = || { + let mut s = session(); + let ids: Vec = (0..4).map(|k| rect(&mut s, 10.0 + 30.0 * f64::from(k), 10.0, 40.0, 60.0, "#ff8000")).collect(); + run(&mut s, "transparency.set", json!({"ids": ids, "opacity": 50})); + (s, ids) + }; + let eps = |s: &mut Session, balance: u32| { + let r = run(s, "document.exportEps", json!({"flattener": {"balance": balance}, "previewFormat": "none", "thumbnails": false})); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + String::from_utf8_lossy(&bytes).lines().filter(|l| !l.contains("CreationDate")).collect::>().join("\n") + }; + for (balance, vector, rasterized) in [(20, 7, 0), (17, 7, 0), (16, 0, 1), (5, 0, 1)] { + let (mut s, _) = setup(); + let o = FlattenOptions::from_params(&json!({"balance": balance})).unwrap(); + let src = s.doc().unwrap().doc.clone(); + let flat = flatten_document(&src, &o).unwrap(); + assert!(flat == every(|| flatten_document(&src, &o).unwrap()), "balance {balance}: flattened document"); + assert_eq!(eps(&mut s, balance), every(|| eps(&mut s, balance)), "balance {balance}: EPS"); + let (mut s, ids) = setup(); + let got = flatten(&mut s, json!({"ids": ids, "balance": balance})); + assert_eq!((got.1, got.2), (vector, rasterized), "balance {balance}"); + let (mut t, ids) = setup(); + let want = every(|| flatten(&mut t, json!({"ids": ids, "balance": balance}))); + assert_eq!(got, want, "balance {balance}"); + // The two documents were made apart, so their creation times can differ. + let (a, b) = (&s.doc().unwrap().doc, &t.doc().unwrap().doc); + assert!(a.layers == b.layers && a.images == b.images, "balance {balance}: Flatten Transparency"); + } +} + #[test] fn gradients_rasterize_and_flat_areas_stay_vector() { let _settings = settings_lock(); diff --git a/crates/engine/src/tests/history_document.rs b/crates/engine/src/tests/history_document.rs new file mode 100644 index 000000000..0c78b1109 --- /dev/null +++ b/crates/engine/src/tests/history_document.rs @@ -0,0 +1,361 @@ +//! History panel › New Document from State (`edit.newDocumentFromState`). + +use std::sync::Arc; + +use serde_json::{Value, json}; + +use super::{rect, session}; +use crate::tests_links::{Folder, RED, place, png, write}; +use crate::*; + +const CMD: &str = "edit.newDocumentFromState"; + +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.layers[0].children().unwrap().len() +} + +fn x(s: &Session, id: NodeId) -> f64 { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 +} + +fn steps(s: &Session) -> (usize, usize) { + let h = &s.doc().unwrap().history; + (h.undo.len(), h.redo.len()) +} + +/// Three rectangles, the last one undone: rows 0 to 3 hold 0 to 3 of them, the current one row 2. +fn three_rectangles_one_undone() -> Session { + let mut s = session(); + for x in [10.0, 200.0, 400.0] { + rect(&mut s, x, 10.0, 100.0, 50.0); + } + s.execute("edit.undo", &json!({})).unwrap(); + s +} + +/// Each row gives the art of its state, the steps to redo included. An omitted or null index gives +/// the current state. Each new document is added and made active. +#[test] +fn a_new_document_holds_the_art_of_the_state() { + let params = [ + (json!({"index": 0}), 0), + (json!({"index": 1}), 1), + (json!({"index": 2}), 2), + (json!({"index": 3}), 3), + (json!({}), 2), + (json!({"index": null}), 2), + ]; + for (p, art) in params { + let mut s = three_rectangles_one_undone(); + let r = s.execute(CMD, &p).unwrap(); + assert_eq!((r["index"].as_u64(), s.active_index()), (Some(1), Some(1)), "{p}"); + assert_eq!(objects(&s), art, "{p}"); + assert_eq!(s.documents().len(), 2); + } +} + +/// The source's document, steps, selection and saved state stay as they are, for a modified source +/// and for one at its saved state. +#[test] +fn the_source_stays_as_it_is() { + let mut s = three_rectangles_one_undone(); + let source = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + assert!(s.doc().unwrap().is_dirty()); + s.execute(CMD, &json!({"index": 0})).unwrap(); + let st = &s.documents()[0]; + assert!(Arc::ptr_eq(&st.doc, &source)); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (2, 1)); + assert_eq!(st.selection, selection); + assert!(st.is_dirty()); + + let mut s = session(); + rect(&mut s, 10.0, 10.0, 100.0, 50.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + s.execute(CMD, &json!({"index": 1})).unwrap(); + assert!(!s.documents()[0].is_dirty(), "the source stays at its saved state"); + assert!(s.doc().unwrap().is_dirty(), "the new document is unsaved"); +} + +/// An index that names no state, or is not a whole number, is an error and changes nothing: no +/// document, no step and no Untitled number is used up. +#[test] +fn a_bad_index_is_an_error_and_changes_nothing() { + let mut s = three_rectangles_one_undone(); + let source = s.doc().unwrap().doc.clone(); + let untitled = s.peek_untitled(); + let bad: [Value; 8] = [json!(4), json!(99), json!(-1), json!(1.5), json!("1"), json!(true), json!([1]), json!(u64::MAX)]; + for index in bad { + let e = s.execute(CMD, &json!({ "index": index })).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{index}: {e}"); + assert!(e.to_string().contains(&format!("no history state {index}")), "{e}"); + assert_eq!(s.documents().len(), 1, "{index}"); + assert_eq!(steps(&s), (2, 1), "{index}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &source), "{index}"); + assert_eq!(s.peek_untitled(), untitled, "{index}"); + } + let e = s.execute(CMD, &json!({"created": "today"})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{e}"); + assert_eq!(s.documents().len(), 1); +} + +/// With no document the command is disabled. +#[test] +fn it_needs_a_document() { + let mut s = Session::new(); + assert!(matches!(s.execute(CMD, &json!({})), Err(EngineError::Disabled(..)))); +} + +/// An edit in either document leaves the other as it is, and so does taking an artboard id. +#[test] +fn the_two_documents_are_independent() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0); + rect(&mut s, 200.0, 10.0, 100.0, 50.0); + s.execute(CMD, &json!({"index": 1})).unwrap(); + s.execute("object.move", &json!({"ids": [a.0], "dx": 30, "dy": 0})).unwrap(); + rect(&mut s, 400.0, 10.0, 100.0, 50.0); + assert_eq!((x(&s, a), objects(&s)), (40.0, 2)); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!((x(&s, a), objects(&s)), (10.0, 2), "the source"); + s.execute("object.move", &json!({"ids": [a.0], "dx": 5, "dy": 0})).unwrap(); + assert_eq!(x(&s, a), 15.0); + let source_board = s.doc().unwrap().doc.next_artboard_id(); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + assert_eq!((x(&s, a), objects(&s)), (40.0, 2), "the new document"); + assert!(s.doc().unwrap().doc.retired_artboard_id.get() < source_board, "an artboard id the source took"); +} + +/// The new document starts with no steps, no selection, no path and the next untitled name, and is +/// unsaved. Undo and Redo then work in it alone, and undoing back to the start leaves it unsaved. +#[test] +fn undo_in_the_new_document() { + let mut s = three_rectangles_one_undone(); + s.execute("select.all", &json!({})).unwrap(); + let r = s.execute(CMD, &json!({"index": 1})).unwrap(); + assert_eq!(r["title"], "Untitled-2"); + let st = s.doc().unwrap(); + assert_eq!(st.title(), "Untitled-2"); + assert_eq!(st.path, None); + assert!(st.selection.objects.is_empty()); + assert!(st.is_dirty()); + let start = st.doc.clone(); + assert_eq!(steps(&s), (0, 0)); + assert!(matches!(s.execute("edit.undo", &json!({})), Err(EngineError::Disabled(..))), "the source's steps are not its own"); + rect(&mut s, 600.0, 10.0, 100.0, 50.0); + assert_eq!(objects(&s), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s), 1); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &start)); + assert!(s.doc().unwrap().is_dirty(), "still unsaved at the state it started from"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(objects(&s), 2); + assert_eq!(s.documents()[0].history.undo.len(), 2, "the source's steps"); +} + +/// A drag in progress is first kept as a step and its change goes into the new document. An index +/// names the state as listed before that, and a bad one leaves the drag going on. +#[test] +fn a_drag_in_progress_is_kept_first() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0); + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); + assert!(s.execute(CMD, &json!({"index": 2})).is_err()); + assert!(s.in_interaction(), "a bad index ends nothing"); + assert_eq!(steps(&s), (1, 0)); + s.execute(CMD, &json!({"index": 0})).unwrap(); + assert_eq!(objects(&s), 0, "the row listed as 0"); + assert_eq!(x_of_source(&s, a), 40.0); + assert_eq!(s.documents()[0].history.undo.len(), 2, "the drag is a step in the source"); + + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0); + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(x(&s, a), 40.0, "the current state holds the drag"); + assert_eq!(x_of_source(&s, a), 40.0); + assert!(s.documents()[0].interaction.is_none()); +} + +fn x_of_source(s: &Session, id: NodeId) -> f64 { + s.documents()[0].doc.node(id).unwrap().geometric_bounds().unwrap().x0 +} + +/// A state saved while editing a symbol gives a document out of that mode and without the +/// temporary editing layer. The source stays in the mode. +#[test] +fn a_new_document_leaves_the_editing_modes() { + let mut s = session(); + rect(&mut s, 10.0, 10.0, 100.0, 50.0); + s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + let layers = s.doc().unwrap().doc.layers.len(); + s.execute("symbol.edit", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.symbol_edit.is_some()); + assert_eq!(s.doc().unwrap().doc.layers.len(), layers + 1); + s.execute(CMD, &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.symbol_edit.is_none()); + assert_eq!(st.doc.layers.len(), layers); + assert!(s.documents()[0].doc.symbol_edit.is_some()); +} + +/// A state saved while editing a pattern gives a document out of that mode, without the editing +/// layer, and with the art drawn in the tile stored in the pattern, as Done stores it. The source +/// stays in the mode. +#[test] +fn a_new_document_finishes_pattern_editing() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let layers = s.doc().unwrap().doc.layers.len(); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + rect(&mut s, 2.0, 2.0, 3.0, 3.0); + assert_eq!(s.doc().unwrap().doc.layers.len(), layers + 1); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let st = s.doc().unwrap(); + assert!(st.doc.pattern_edit.is_none()); + assert_eq!(st.doc.layers.len(), layers); + assert_eq!(st.doc.pattern("P").map(|p| p.art.len()), Some(2)); + assert_eq!(st.isolation, None); + assert!(s.documents()[0].doc.pattern_edit.is_some()); +} + +/// A linked image still finds its file from the new document, which has no folder of its own, and +/// its pixels come along. +#[test] +fn links_resolve_in_the_new_document() { + let dir = Folder::new("history-document-links"); + let pic = dir.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let list = |s: &mut Session| s.execute("links.list", &json!({})).unwrap(); + assert_eq!(list(&mut s)["links"][0]["status"], "ok"); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let links = list(&mut s); + assert_eq!((&links["links"][0]["status"], &links["links"][0]["path"]), (&json!("ok"), &json!(pic)), "{links}"); + let st = s.doc().unwrap(); + assert!(st.doc.node(id).is_some()); + assert_eq!(st.doc.images.len(), s.documents()[0].doc.images.len()); +} + +/// A linked file the source finds in its own folder, away from the link's path, is found from the +/// new document too: its link takes the path where the source finds the file. +#[test] +fn a_file_the_source_finds_in_its_folder_is_found_from_the_new_document() { + let a = Folder::new("history-document-moved-a"); + let b = Folder::new("history-document-moved-b"); + let pic = a.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &pic); + s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + let moved = b.file("photo.png"); + std::fs::rename(&pic, &moved).unwrap(); + let check = |s: &mut Session| s.execute("links.check", &json!({})).unwrap(); + assert_eq!(check(&mut s)["links"][0]["status"], "ok", "the source finds it by name in its folder"); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let c = check(&mut s); + assert_eq!((&c["links"][0]["status"], &c["links"][0]["path"]), (&json!("ok"), &json!(moved)), "{c}"); +} + +/// The same holds for a linked image only a symbol shows. +#[test] +fn a_file_a_symbol_shows_is_found_from_the_new_document() { + let a = Folder::new("history-document-symbol-a"); + let b = Folder::new("history-document-symbol-b"); + let pic = a.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &pic); + s.execute("symbol.new", &json!({"name": "Photo"})).unwrap(); + s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + let moved = b.file("photo.png"); + std::fs::rename(&pic, &moved).unwrap(); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let d = &s.doc().unwrap().doc; + assert!(!d.symbols.is_empty()); + let mut paths = vec![]; + d.visit_images(|_, im| paths.extend(im.link.as_ref().map(|l| l.path.clone()))); + assert!(!paths.is_empty() && paths.iter().all(|p| *p == moved), "{paths:?}"); +} + +/// Files are looked for only within the roots of the caller that opened the source, or of a +/// confined caller asking now: a file outside them is not linked from the new document. +#[test] +fn a_file_outside_the_callers_roots_is_not_looked_for() { + let a = Folder::new("history-document-confined-a"); + let b = Folder::new("history-document-confined-b"); + let other = Folder::new("history-document-confined-other"); + let pic = a.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &pic); + s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + let moved = b.file("photo.png"); + std::fs::rename(&pic, &moved).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&other.0], &[]).unwrap().unwrap(); + let link = |s: &Session| { + let mut paths = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| paths.extend(im.link.as_ref().map(|l| l.path.clone()))); + paths + }; + crate::file_access::confine(Some(&narrow), || s.execute(CMD, &json!({}))).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + assert!(link(&s).iter().all(|p| *p != moved), "a confined caller: {:?}", link(&s)); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.active_mut().unwrap().opened_by = Some(narrow); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(2), "the second new document"); + let paths = link(&s); + assert!(!paths.is_empty() && paths.iter().all(|p| *p != moved), "opened by a confined caller: {paths:?}"); +} + +/// What reading the source's file left out still guards that file from the new document, and the +/// new document is read within what the caller that opened the source may read. +#[test] +fn the_new_document_keeps_what_guards_the_source() { + let dir = Folder::new("history-document-roots"); + let roots = crate::file_access::AutomationRoots::new(&[&dir.0], &[]).unwrap().unwrap(); + let mut s = session(); + let st = s.active_mut().unwrap(); + st.imported_from = Some("art.ai".into()); + st.import_losses = vec!["hidden text".into()]; + st.opened_by = Some(roots.clone()); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let st = s.doc().unwrap(); + assert_eq!((st.imported_from.as_deref(), st.import_losses.as_slice()), (Some("art.ai"), &["hidden text".to_string()][..])); + assert!(st.opened_by.as_ref().is_some_and(|r| Arc::ptr_eq(r, &roots))); +} + +/// The created date is the new document's own and joins the journal entry, so a replay gives the +/// same date. An action leaves it out. The new document has no modified date until it is saved. +#[test] +fn the_created_date_is_the_new_documents_own() { + let mut s = session(); + Arc::make_mut(&mut s.active_mut().unwrap().doc).metadata.modified = Some(500); + let source = s.doc().unwrap().doc.metadata.created; + s.execute(CMD, &json!({"created": 1000})).unwrap(); + assert_eq!(s.doc().unwrap().doc.metadata.created, Some(1000)); + assert_eq!(s.doc().unwrap().doc.metadata.modified, None, "the new document has not been saved"); + assert_eq!(s.documents()[0].doc.metadata.modified, Some(500)); + assert_eq!(s.documents()[0].doc.metadata.created, source); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute(CMD, &json!({"created": null})).unwrap(); + assert_eq!(s.doc().unwrap().doc.metadata.created, None); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute(CMD, &json!({})).unwrap(); + let noted = s.journal.last().unwrap().1["created"].as_i64(); + assert!(noted.is_some() && noted == s.doc().unwrap().doc.metadata.created, "{noted:?}"); + let mut action = s.journal.last().unwrap().1.clone(); + crate::command_sets::strip_replay_only(CMD, &mut action); + assert_eq!(action, json!({}), "an action leaves the date out"); +} diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 26fcc0835..6d4e1a084 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -268,9 +268,24 @@ fn artboard_commands_record_the_selected_artboards_for_a_replay_and_not_for_an_a for (id, params, recorded, action) in [ ("artboard.switchOrientation", json!({}), json!({"selectedArtboards": [1], "scaleArt": false}), json!({"scaleArt": false})), ("artboard.rename", json!({"name": "Back"}), json!({"name": "Back", "selectedArtboards": [1]}), json!({"name": "Back"})), - ("artboard.duplicate", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), - ("artboard.cut", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), - ("artboard.copy", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), + ( + "artboard.duplicate", + json!({}), + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + json!({"art": true, "lockedAndHidden": false}), + ), + ( + "artboard.cut", + json!({}), + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + json!({"art": true, "lockedAndHidden": false}), + ), + ( + "artboard.copy", + json!({}), + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + json!({"art": true, "lockedAndHidden": false}), + ), ] { let mut s = Session::new(); let start = second_artboard_selected(&mut s); @@ -303,14 +318,14 @@ fn artboard_commands_keep_a_named_artboard_and_note_a_batch_steps_artboards() { s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap(); assert_eq!(s.journal.last().unwrap().1, json!({"index": 0, "scaleArt": false})); s.execute("artboard.duplicate", &json!({"indices": [0]})).unwrap(); - assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true})); + assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true, "lockedAndHidden": false})); let steps = json!([{"command": "artboard.switchOrientation", "params": {}}]); s.execute("command.batch", &json!({ "commands": steps })).unwrap(); assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "scaleArt": false})); assert_eq!(documents(&replayed(&s)), documents(&s)); let action = s.journal_for_action(start); assert_eq!(action[0].1, json!({"index": 0, "scaleArt": false})); - assert_eq!(action[1].1, json!({"indices": [0], "art": true})); + assert_eq!(action[1].1, json!({"indices": [0], "art": true, "lockedAndHidden": false})); assert_eq!(action[2].1["commands"][0]["params"], json!({"scaleArt": false})); } @@ -329,14 +344,14 @@ fn artboard_commands_read_the_recorded_selected_artboards() { } /// `edit.copy` and `edit.cut` run the Artboard tool's copy and take no artboards, so their entries -/// stay as given. +/// stay as given, apart from the locked-art preference the Artboard tool's copy notes. #[test] fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() { for id in ["edit.copy", "edit.cut"] { let mut s = Session::new(); second_artboard_selected(&mut s); s.execute(id, &json!({})).unwrap(); - assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}"); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({"lockedAndHidden": false})), "{id}"); } } diff --git a/crates/engine/src/tests/large_edits.rs b/crates/engine/src/tests/large_edits.rs index 97405d6f6..aa18cef1e 100644 --- a/crates/engine/src/tests/large_edits.rs +++ b/crates/engine/src/tests/large_edits.rs @@ -1,6 +1,6 @@ -//! Commands that remove, insert or copy many objects, and an Alt-drag that copies them, each finish -//! within 20 s on one layer of 25,000 to 100,000 objects, and objects in two layers and inside a -//! group keep their order and ids. +//! Commands that remove, insert or copy many objects, and an Alt-drag that copies them, each walk +//! the document's tree a few times on one layer of 25,000 to 100,000 objects, and objects in two +//! layers and inside a group keep their order and ids. use std::sync::Arc; use std::time::Duration; @@ -65,14 +65,22 @@ fn in_turn(a: &[NodeId], b: &[NodeId]) -> Vec { a.iter().zip(b).flat_map(|(a, b)| [*a, *b]).collect() } -/// Runs `f` on `s` on a thread of its own and fails the test when it takes more than 20 s. +/// Most walks of the document's tree ([`vectorcraft_doc::tree_walks`]) that [`soon`] allows, however +/// many objects the document holds. A command that looks its objects up one at a time walks the tree +/// about once per object. +const MOST_TREE_WALKS: u64 = 64; + +/// Runs `f` on `s` on a thread of its own and fails the test when it walks the document's tree more +/// than [`MOST_TREE_WALKS`] times or has not ended after 300 s. pub(crate) fn soon(mut s: Session, what: &'static str, f: impl FnOnce(&mut Session) -> T + Send + 'static) -> (Session, T) { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let r = f(&mut s); - tx.send((s, r)).ok(); + tx.send((s, r, vectorcraft_doc::tree_walks())).ok(); }); - rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("{what} did not finish within 20 s")) + let (s, r, walks) = rx.recv_timeout(Duration::from_secs(300)).unwrap_or_else(|_| panic!("{what} did not finish within 300 s")); + assert!(walks <= MOST_TREE_WALKS, "{what} walked the document's tree {walks} times"); + (s, r) } /// Runs command `cmd` with `params` through [`soon`] and returns its result. @@ -103,7 +111,7 @@ fn bounds(s: &Session, id: NodeId) -> Rect { } #[test] -fn delete_of_100000_objects_finishes_within_20_s() { +fn delete_of_100000_objects_takes_few_tree_walks() { let s = session(100_000, square); let before = s.doc().unwrap().doc.clone(); let (mut s, _) = run_soon(s, "edit.clear", json!({})); @@ -113,7 +121,7 @@ fn delete_of_100000_objects_finishes_within_20_s() { } #[test] -fn delete_selection_of_100000_rows_finishes_within_20_s() { +fn delete_selection_of_100000_rows_takes_few_tree_walks() { let s = session(100_000, square); let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0)); let (mut s, r) = run_soon(s, "layer.delete", json!({"ids": ids})); @@ -124,7 +132,7 @@ fn delete_selection_of_100000_rows_finishes_within_20_s() { } #[test] -fn clean_up_of_100000_unpainted_paths_finishes_within_20_s() { +fn clean_up_of_100000_unpainted_paths_takes_few_tree_walks() { let s = session(100_000, |d, k| Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default())); let before = s.doc().unwrap().doc.clone(); let (mut s, r) = run_soon(s, "object.path.cleanUp", json!({})); @@ -135,7 +143,7 @@ fn clean_up_of_100000_unpainted_paths_finishes_within_20_s() { } #[test] -fn group_of_50000_objects_finishes_within_20_s() { +fn group_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0)); let (mut s, r) = run_soon(s, "object.group", json!({})); @@ -147,7 +155,7 @@ fn group_of_50000_objects_finishes_within_20_s() { } #[test] -fn clipping_mask_of_50000_objects_finishes_within_20_s() { +fn clipping_mask_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0)); let (mut s, r) = run_soon(s, "object.clippingMask.make", json!({})); @@ -165,7 +173,7 @@ fn clipping_mask_of_50000_objects_finishes_within_20_s() { } #[test] -fn duplicate_of_50000_objects_finishes_within_20_s() { +fn duplicate_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "edit.duplicate", json!({"dx": 5})); @@ -179,7 +187,7 @@ fn duplicate_of_50000_objects_finishes_within_20_s() { } #[test] -fn transform_with_copy_of_50000_objects_finishes_within_20_s() { +fn transform_with_copy_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})); @@ -194,7 +202,7 @@ fn transform_with_copy_of_50000_objects_finishes_within_20_s() { } #[test] -fn transform_each_with_copy_of_50000_objects_finishes_within_20_s() { +fn transform_each_with_copy_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "object.transformEach", json!({"moveH": 5, "copy": true})); @@ -207,7 +215,7 @@ fn transform_each_with_copy_of_50000_objects_finishes_within_20_s() { } #[test] -fn offset_path_of_50000_objects_finishes_within_20_s() { +fn offset_path_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "object.path.offsetPath", json!({"offset": 1})); @@ -222,7 +230,7 @@ fn offset_path_of_50000_objects_finishes_within_20_s() { } #[test] -fn compound_path_of_100000_objects_finishes_within_20_s() { +fn compound_path_of_100000_objects_takes_few_tree_walks() { let s = session(100_000, square); let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0)); let (mut s, r) = run_soon(s, "object.compoundPath.make", json!({})); @@ -234,7 +242,7 @@ fn compound_path_of_100000_objects_finishes_within_20_s() { } #[test] -fn send_to_current_layer_of_100000_objects_finishes_within_20_s() { +fn send_to_current_layer_of_100000_objects_takes_few_tree_walks() { let mut s = session(100_000, square); let ids = layer(&s, 0); let to = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); @@ -249,7 +257,7 @@ fn send_to_current_layer_of_100000_objects_finishes_within_20_s() { } #[test] -fn row_drag_of_100000_rows_finishes_within_20_s() { +fn row_drag_of_100000_rows_takes_few_tree_walks() { let mut s = session(100_000, square); let ids = layer(&s, 0); let to = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); @@ -263,7 +271,7 @@ fn row_drag_of_100000_rows_finishes_within_20_s() { } #[test] -fn merge_selected_of_100000_objects_finishes_within_20_s() { +fn merge_selected_of_100000_objects_takes_few_tree_walks() { let mut s = session(100_000, square); let (from, ids) = (s.doc().unwrap().doc.layers[0].id, layer(&s, 0)); let into = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); @@ -277,7 +285,7 @@ fn merge_selected_of_100000_objects_finishes_within_20_s() { } #[test] -fn ungroup_of_25000_groups_finishes_within_20_s() { +fn ungroup_of_25000_groups_takes_few_tree_walks() { // Each group holds two squares, and ungrouping one adds an object to the layer. let s = session(25_000, |d, k| { let pair = [square(d, 2 * k), square(d, 2 * k + 1)].map(Arc::new); @@ -292,7 +300,7 @@ fn ungroup_of_25000_groups_finishes_within_20_s() { } #[test] -fn compound_path_release_of_25000_compound_paths_finishes_within_20_s() { +fn compound_path_release_of_25000_compound_paths_takes_few_tree_walks() { let s = session(25_000, |d, k| { let pair = [square(d, 2 * k), square(d, 2 * k + 1)].map(Arc::new); Node::new(d.alloc_id(), NodeKind::Compound { children: pair.to_vec(), rule: FillRule::NonZero }) @@ -306,7 +314,7 @@ fn compound_path_release_of_25000_compound_paths_finishes_within_20_s() { } #[test] -fn release_to_layers_of_100000_objects_finishes_within_20_s() { +fn release_to_layers_of_100000_objects_takes_few_tree_walks() { let s = session(100_000, square); let doc = &s.doc().unwrap().doc; let (before, l, ids, first) = (doc.clone(), doc.layers[0].id, layer(&s, 0), doc.peek_next_id()); @@ -329,7 +337,7 @@ fn release_to_layers_of_100000_objects_finishes_within_20_s() { } #[test] -fn outline_stroke_of_25000_objects_finishes_within_20_s() { +fn outline_stroke_of_25000_objects_takes_few_tree_walks() { let s = session(25_000, square); let (before, first) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "object.path.outlineStroke", json!({})); @@ -347,7 +355,7 @@ fn outline_stroke_of_25000_objects_finishes_within_20_s() { /// With Alt held, a Selection tool press on one of 30,000 selected squares, four moves of 20 pt /// and the release copy the squares once, as one undo step. #[test] -fn alt_drag_of_30000_objects_finishes_within_20_s() { +fn alt_drag_of_30000_objects_takes_few_tree_walks() { let s = session(30_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); // The press lands on a square in the middle of the art, away from the handles of the diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index a4c222090..cfe43ee8e 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -244,6 +244,203 @@ fn the_relative_path_finds_the_file_after_the_folder_moves() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// Links in symbols, graph designs, patterns and opacity masks are saved with their relative +/// paths, as links in the layers are: after the folder moves, each file is found and its link takes +/// the new path, which the next save writes relative to the document again. +#[test] +fn links_outside_the_layers_find_their_files_after_the_folder_moves() { + let a = Folder::new("nested-move-a"); + let b = Folder::new("nested-move-b"); + let c = Folder::new("nested-move-c"); + let names = ["layer", "symbol", "design", "pattern", "mask"]; + for (i, name) in (0u8..).zip(names) { + write(&a.file(&format!("art/{name}.png")), &png(20, 10, [20 + 40 * i, 90, 160])); + } + let mut s = session(); + place(&mut s, &a.file("art/layer.png")); + let id = place(&mut s, &a.file("art/symbol.png")); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let id = place(&mut s, &a.file("art/design.png")); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let id = place(&mut s, &a.file("art/pattern.png")); + s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); + let mask = place(&mut s, &a.file("art/mask.png")); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); + let mut in_layers = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + in_layers.extend(im.link.as_ref().map(|l| l.name().to_string())); + } + }); + assert_eq!(in_layers, ["layer.png"], "only one file is shown by an object in the layers"); + // The links' paths, each image checked to show its file's pixels rather than the saved preview. + let paths = |s: &Session| { + let d = &s.doc().unwrap().doc; + let mut out = vec![]; + d.visit_images(|_, im| { + let l = im.link.as_ref().unwrap(); + assert!(!d.images[&im.key].is_proxy(), "{} shows its preview", l.name()); + out.push(PathBuf::from(&l.path)); + }); + out.sort(); + out + }; + let files = |dir: &Folder| { + let mut out: Vec = names.iter().map(|n| PathBuf::from(dir.file(&format!("art/{n}.png")))).collect(); + out.sort(); + out + }; + assert_eq!(paths(&s), files(&a)); + save(&mut s, &a.file("poster.vectorcraft")); + // Move the document with its links: the old folder is gone. + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + assert_eq!(paths(&s), files(&b), "the links follow the files"); + // Saved again and moved again. + save(&mut s, &b.file("poster.vectorcraft")); + std::fs::remove_dir_all(&c.0).unwrap(); + std::fs::rename(&b.0, &c.0).unwrap(); + let r = open(&mut s, &c.file("poster.vectorcraft")); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + assert_eq!(paths(&s), files(&c)); +} + +/// With Update Links: Automatically, a file in a symbol and in the layers that changed and moved +/// with the document is read again for the layers' image and for the symbol's, and both links take +/// its new path, size and hash. +#[test] +fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { + let a = Folder::new("nested-modified-a"); + let b = Folder::new("nested-modified-b"); + let pic = a.file("art/photo.png"); + write(&pic, &png(20, 10, RED)); + let mut s = session(); + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let id = place(&mut s, &pic); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let id = place(&mut s, &pic); + save(&mut s, &a.file("poster.vectorcraft")); + write(&pic, &png(30, 30, BLUE)); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}"); + let moved = PathBuf::from(b.file("art/photo.png")); + let blue = png(30, 30, BLUE); + let mut images = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| { + let l = im.link.as_ref().unwrap(); + images.push((PathBuf::from(&l.path), l.size, l.hash.clone(), im.width, im.height, im.key.clone())); + }); + let key = image(&s, id).key; + let new = (moved, Some(blue.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&blue)), 30, 30, key); + assert_eq!(images, [new.clone(), new], "the layers' image, then the symbol's"); +} + +/// A link inside a symbol that the document records without a relative path, beside a link in the +/// layers to the same file that has one, takes the file's new path after the folder moves. +#[test] +fn a_symbols_link_without_a_relative_path_follows_the_moved_file() { + let a = Folder::new("nested-unrelative-a"); + let b = Folder::new("nested-unrelative-b"); + let pic = a.file("art/photo.png"); + write(&pic, &png(20, 10, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + place(&mut s, &pic); + let doc_path = a.file("poster.vectorcraft"); + save(&mut s, &doc_path); + // Removes the relative path of every link under `v` → how many it removed. + fn unrelative(v: &mut Value) -> usize { + match v { + Value::Object(o) => { + let own = match o.get_mut("link") { + Some(Value::Object(l)) => usize::from(l.remove("relative").is_some()), + _ => 0, + }; + own + o.values_mut().map(unrelative).sum::() + } + Value::Array(a) => a.iter_mut().map(unrelative).sum(), + _ => 0, + } + } + let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + assert_eq!(unrelative(&mut saved["document"]["symbols"]), 1, "the symbol's link"); + assert_eq!(unrelative(&mut saved["document"]["layers"].clone()), 1, "the layers' link keeps its relative path"); + std::fs::write(&doc_path, serde_json::to_vec(&saved).unwrap()).unwrap(); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + let mut paths = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| paths.push(PathBuf::from(&im.link.as_ref().unwrap().path))); + let moved = PathBuf::from(b.file("art/photo.png")); + assert_eq!(paths, [moved.clone(), moved], "the layers' link, then the symbol's"); +} + +/// Update Links reads a changed file again for the images inside symbols, patterns and opacity +/// masks too: those showing the same content as an image in the layers, and those no object in the +/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step. +#[test] +fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { + let dir = Folder::new("nested-update"); + let (photo, logo) = (dir.file("photo.png"), dir.file("logo.png")); + write(&photo, &png(20, 10, RED)); + write(&logo, &png(20, 10, [200, 200, 20])); + let mut s = session(); + // photo.png in the layers, and in a symbol filling its bounds, clipped to them. + let id = place(&mut s, &photo); + s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let id = place(&mut s, &photo); + // logo.png only in a pattern and in an opacity mask. + let p = place(&mut s, &logo); + s.execute("object.pattern.make", &json!({"ids": [p.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [p.0]})).unwrap(); + let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); + let mask = place(&mut s, &logo); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); + // photo.png wider than the symbol's image is: filling its bounds overflows them. + let (blue, green) = (png(60, 20, BLUE), png(40, 20, [20, 200, 20])); + write(&photo, &blue); + write(&logo, &green); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u, json!({"updated": [id.0], "missing": []})); + // (name, size, hash, pixel size) of each image, which shows its file's pixels. + let images = |s: &Session| { + let d = &s.doc().unwrap().doc; + let mut out = vec![]; + d.visit_images(|_, im| { + let l = im.link.as_ref().unwrap(); + assert!(!d.images[&im.key].is_proxy(), "{} shows its preview", l.name()); + out.push((l.name().to_string(), l.size, l.hash.clone(), (im.width, im.height))); + }); + out.sort(); + out + }; + let shown = |name: &str, bytes: &[u8], size| (name.to_string(), Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(bytes)), size); + let logo_now = shown("logo.png", &green, (40, 20)); + let photo_now = shown("photo.png", &blue, (60, 20)); + assert_eq!(images(&s), [logo_now.clone(), logo_now, photo_now.clone(), photo_now]); + // The symbol's image is clipped to its old bounds. + let mut clipped = false; + s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| { + if let NodeKind::Group { children, clip: true } = &n.kind { + clipped |= + matches!(&children[..], [c, im] if matches!(c.kind, NodeKind::Path { clipping: true, .. }) && matches!(im.kind, NodeKind::Image(_))); + } + }); + assert!(clipped, "{:?}", s.doc().unwrap().doc.symbols[0].art); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s)); +} + #[test] fn a_missing_link_renders_its_preview_and_relinks() { let dir = Folder::new("missing"); @@ -283,6 +480,151 @@ fn a_missing_link_renders_its_preview_and_relinks() { assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder"); } +/// The path of the file image `id` links to. +fn link_path(s: &Session, id: NodeId) -> String { + image(s, id).link.map(|l| l.path).unwrap_or_default() +} + +/// Replace: All Instances relinks every image linked to the same file as the one given, in one +/// undo step; Selected Instances (the default) only the one given. +#[test] +fn relink_all_instances_takes_every_image_of_the_same_file() { + let dir = Folder::new("relink-all"); + let (pic, other) = (dir.file("photo.png"), dir.file("other.png")); + write(&pic, &png(60, 30, RED)); + write(&other, &png(60, 30, RED)); + let mut s = session(); + let (a, b, c) = (place(&mut s, &pic), place(&mut s, &pic), place(&mut s, &other)); + let blue = dir.file("new/blue.png"); + write(&blue, &png(60, 30, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "selected"})).unwrap(); + assert_eq!(r, json!({"relinked": [a.0], "notFound": []})); + assert_eq!(link_path(&s, b), pic, "selected instances: the other copy keeps its file"); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "all"})).unwrap(); + assert_eq!(r, json!({"relinked": [a.0, b.0], "notFound": []})); + assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (blue.clone(), blue.clone(), other.clone())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((link_path(&s, a), link_path(&s, b)), (pic.clone(), pic.clone()), "one undo step"); + // The selection stands in for ids. + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let r = s.execute("links.relink", &json!({"path": blue, "scope": "all"})).unwrap(); + assert_eq!(r["relinked"], json!([b.0, a.0])); +} + +/// Replace: Missing Links from This Folder relinks the given links to the chosen file, then every +/// other missing link to the file of its name in that file's folder, in one undo step; names not +/// there are listed in notFound and stay missing. +#[test] +fn relink_missing_links_from_this_folder_finds_the_others_by_name() { + let dir = Folder::new("relink-missing-folder"); + let names = ["a.png", "b.png", "c.png", "d.png"]; + let mut s = session(); + let ids: Vec = names + .iter() + .map(|n| { + write(&dir.file(n), &png(60, 30, RED)); + place(&mut s, &dir.file(n)) + }) + .collect(); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + // d.png stays where it was: it isn't missing. + for n in &names[..3] { + std::fs::remove_file(dir.file(n)).unwrap(); + } + open(&mut s, &doc_path); + let found = dir.file("found/a new name.png"); + write(&found, &png(60, 30, BLUE)); + write(&dir.file("found/b.png"), &png(60, 30, BLUE)); + write(&dir.file("found/d.png"), &png(60, 30, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [ids[0].0], "path": found, "scope": "missingInFolder"})).unwrap(); + assert_eq!(r, json!({"relinked": [ids[0].0, ids[1].0], "notFound": ["c.png"]})); + let b_found = dir.0.join("found").join("b.png").to_string_lossy().into_owned(); + assert_eq!((link_path(&s, ids[0]), link_path(&s, ids[1])), (found.clone(), b_found)); + assert_eq!((link_path(&s, ids[2]), link_path(&s, ids[3])), (dir.file("c.png"), dir.file("d.png"))); + let c = s.execute("links.check", &json!({})).unwrap(); + assert_eq!(c["missing"], 1, "{c}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 3, "one undo step"); +} + +/// Replace: Each Link Separately takes one file for each link, in one undo step. +#[test] +fn relink_each_link_separately_takes_a_file_for_each() { + let dir = Folder::new("relink-each"); + let (one, two) = (dir.file("one.png"), dir.file("two.png")); + write(&one, &png(60, 30, RED)); + write(&two, &png(60, 30, RED)); + let mut s = session(); + let (a, b, c) = (place(&mut s, &one), place(&mut s, &one), place(&mut s, &two)); + let (x, y) = (dir.file("new/x.png"), dir.file("new/y.png")); + write(&x, &png(60, 30, BLUE)); + write(&y, &png(30, 30, BLUE)); + let paths = json!([{"ids": [a.0, b.0], "path": x}, {"ids": [c.0], "path": y}]); + let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap(); + assert_eq!(r, json!({"relinked": [a.0, b.0, c.0], "notFound": []})); + assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (x.clone(), x.clone(), y.clone())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((link_path(&s, a), link_path(&s, c)), (one.clone(), two.clone()), "one undo step"); + // Two entries naming the same file read it once. + let paths = json!([{"ids": [a.0], "path": y}, {"ids": [c.0], "path": y}]); + let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap(); + assert_eq!(r["relinked"], json!([a.0, c.0])); +} + +/// With copy, a file that can't be shown stops the call before any file is copied into the +/// document's folder. +#[test] +fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() { + let dir = Folder::new("relink-each-copy"); + let (one, two) = (dir.file("doc/one.png"), dir.file("doc/two.png")); + write(&one, &png(60, 30, RED)); + write(&two, &png(60, 30, RED)); + let mut s = session(); + let (a, c) = (place(&mut s, &one), place(&mut s, &two)); + save(&mut s, &dir.file("doc/poster.vectorcraft")); + let (x, bad) = (dir.file("new/x.png"), dir.file("new/broken.png")); + write(&x, &png(60, 30, BLUE)); + write(&bad, b"not an image"); + let paths = json!([{"ids": [a.0], "path": x}, {"ids": [c.0], "path": bad}]); + assert!(s.execute("links.relink", &json!({"scope": "each", "paths": paths, "copy": true})).is_err()); + assert!(!Path::new(&dir.file("doc/x.png")).exists(), "no copy is left behind"); + assert_eq!(link_path(&s, a), one); +} + +/// A scope or paths that doesn't fit is refused, and nothing is relinked. +#[test] +fn relink_refuses_scopes_and_paths_that_dont_fit() { + let dir = Folder::new("relink-bad-scope"); + let pic = dir.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + let a = place(&mut s, &pic); + let blue = dir.file("blue.png"); + write(&blue, &png(60, 30, BLUE)); + let bad = [ + json!({"ids": [a.0], "path": blue, "scope": "everything"}), + json!({"ids": [a.0], "path": blue, "scope": 3}), + json!({"scope": "each"}), + json!({"scope": "each", "paths": []}), + json!({"scope": "each", "paths": [{"ids": [a.0]}]}), + json!({"scope": "each", "paths": [{"path": blue}]}), + json!({"scope": "each", "paths": [{"ids": ["one"], "path": blue}]}), + json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}, {"ids": [a.0], "path": pic}]}), + json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "path": blue}), + json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "ids": [a.0]}), + json!({"scope": "all", "paths": [{"ids": [a.0], "path": blue}]}), + json!({"ids": [a.0], "paths": [{"ids": [a.0], "path": blue}]}), + json!({"ids": [a.0], "folder": dir.0.to_string_lossy(), "scope": "missingInFolder"}), + json!({"ids": [a.0], "scope": "missingInFolder"}), + ]; + for p in bad { + assert!(s.execute("links.relink", &p).is_err(), "{p}"); + assert_eq!(link_path(&s, a), pic, "{p} relinks nothing"); + } +} + /// A link stored as a classic Mac OS path (`Disk:Folder:file`, as older `.ai` files keep them) is /// named by its last component: the Links panel shows that name and its folder, Relink to Folder /// looks for that name, and the document's own folder is searched for it. The stored path is kept. @@ -452,6 +794,63 @@ fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "canvas still shows the preview"); } +/// An SVG linking two missing files: once one of them is found, export reads its pixels into its +/// own images only, and the other file's image keeps its placeholder. +#[test] +fn export_reads_a_found_svg_link_into_its_own_images_only() { + let dir = Folder::new("svg-missing-keys"); + let svg = r#""#; + let svg_path = dir.file("art.svg"); + write(&svg_path, svg.as_bytes()); + let mut s = session(); + open(&mut s, &svg_path); + let mut found = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone())); + let [a, b] = &found[..] else { panic!("two images: {found:?}") }; + assert!(a.link.as_ref().unwrap().path.ends_with("a.png") && b.link.as_ref().unwrap().path.ends_with("b.png")); + let placeholder = s.doc().unwrap().doc.images[&b.key].bytes.clone(); + + let bytes = png(20, 20, RED); + write(&dir.file("a.png"), &bytes); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + assert_eq!(warnings.len(), 1, "b.png is still missing: {warnings:?}"); + assert_eq!(*full.images[&a.key].bytes, bytes, "a.png's pixels"); + assert_eq!(full.images[&b.key].bytes, placeholder, "b.png keeps its placeholder"); +} + +/// An SVG linking two files with the same pixels, saved with previews only: once one file is gone +/// and the other has changed, export reads the changed file into its own images only. +#[test] +fn export_reads_a_changed_svg_link_into_its_own_images_only() { + let dir = Folder::new("svg-same-pixels"); + let svg = r#""#; + let svg_path = dir.file("art.svg"); + write(&svg_path, svg.as_bytes()); + write(&dir.file("a.png"), &png(600, 300, RED)); + write(&dir.file("b.png"), &png(600, 300, RED)); + let mut s = session(); + open(&mut s, &svg_path); + let doc_path = dir.file("art.vectorcraft"); + save(&mut s, &doc_path); + std::fs::remove_file(dir.file("a.png")).unwrap(); + let blue = png(600, 300, BLUE); + write(&dir.file("b.png"), &blue); + + open(&mut s, &doc_path); + let mut found = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone())); + let [a, b] = &found[..] else { panic!("two images: {found:?}") }; + assert!(a.link.as_ref().unwrap().path.ends_with("a.png") && b.link.as_ref().unwrap().path.ends_with("b.png")); + assert_ne!(a.key, b.key, "two files, two keys"); + let doc = &s.doc().unwrap().doc; + assert!(doc.images[&a.key].is_proxy() && doc.images[&b.key].is_proxy(), "previews"); + let preview = doc.images[&a.key].bytes.clone(); + let (full, warnings) = cmd::links::full_linked_images(doc, None); + assert_eq!(warnings.len(), 1, "a.png is missing: {warnings:?}"); + assert_eq!(*full.images[&b.key].bytes, blue, "b.png's new pixels"); + assert_eq!(full.images[&a.key].bytes, preview, "a.png keeps its preview"); +} + /// The half of opening that the desktop app runs on a thread of its own (`read_for_open`) reads /// the linked files and lays out the type. The half left for the UI thread (`open_read`) reads no /// file: with a linked file moved away in between, it opens the document that `document.open` opens. diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 1dc5f66ba..c7479ce8f 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::live::{self, BlendOrientation, BlendSpacing, EnvelopeKind}; +use vectorcraft_doc::live::{self, BlendOrientation, BlendSpacing, EnvelopeKind, PreserveShape}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; @@ -87,6 +87,12 @@ fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) { assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); } +/// `cmd` with `p` fails with the error `want`. +fn refuses_with(s: &mut Session, cmd: &str, p: &Value, want: &str) { + let r = s.execute(cmd, p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{cmd} {p}: {r:?}"); +} + // ---------- Blend ---------- #[test] @@ -231,13 +237,13 @@ fn blend_smooth_colour_steps_from_colour_distance() { let mut s = session(); let (_, _, g) = blend(&mut s, json!({})); let n = steps_of(&s, g).len() - 2; - assert_eq!(n, 128, "black→white: one step per two levels"); - // Consecutive steps differ by at most ~2 levels. + assert_eq!(n, 254, "black→white: one step per level"); + // Consecutive steps differ by about a level. let st = steps_of(&s, g); for w in st.windows(2) { let x = w[0].appearance.fill_paint().color().unwrap().to_rgb()[0]; let y = w[1].appearance.fill_paint().color().unwrap().to_rgb()[0]; - assert!((x - y).abs() <= 2.5 / 255.0); + assert!((x - y).abs() <= 1.5 / 255.0); } } @@ -248,7 +254,9 @@ fn blend_smooth_colour_same_colours_uses_distance() { let b = rect(&mut s, 40.0, 0.0, 10.0, 10.0); sel(&mut s, &[a, b]); let g = id_of(&s.execute("object.blend.make", &json!({"smooth": true})).unwrap()); - assert_eq!(steps_of(&s, g).len() - 2, 20); + // The edges are 40 pt apart, 3.6 times the keys' 11 pt visible width (their 1 pt strokes + // included): rounded, less one. + assert_eq!(steps_of(&s, g).len() - 2, 3); } #[test] @@ -745,6 +753,73 @@ fn envelope_options_refuse_a_boolean_given_as_text() { assert!(matches!(&node(&s, warp).kind, NodeKind::Envelope { options, .. } if !options.anti_alias && options.distort_appearance)); } +/// Make with Warp, Reset with Warp and Envelope Options refuse a warp `style` that is not text or +/// names no warp, and an `orientation` other than horizontal or vertical: no envelope is made, a +/// warp envelope keeps its warp, and no undo step is added. Null is read as the key left out. +#[test] +fn envelope_warps_refuse_an_invalid_style_or_orientation() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let n = undo_len(&s); + let calls = [ + (json!({"style": 5}), "`style` must be a string"), + (json!({"style": ["arch"], "bend": 40}), "`style` must be a string"), + (json!({"style": "foo"}), "unknown warp style `foo`"), + (json!({"orientation": 1}), "`orientation` must be one of horizontal, vertical"), + (json!({"orientation": true, "horizontal": true}), "`orientation` must be one of horizontal, vertical"), + (json!({"orientation": "diagonal"}), "`orientation` must be one of horizontal, vertical"), + ]; + for (p, want) in &calls { + refuses_with(&mut s, "object.envelope.makeWithWarp", p, want); + } + assert_eq!(undo_len(&s), n, "nothing changed"); + assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped"); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": null, "orientation": null, "bend": 30})).unwrap()); + assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } + if matches!(&**kind, EnvelopeKind::Warp { style, horizontal: true, .. } if style == "arc"))); + let (before, n) = (node(&s, e), undo_len(&s)); + for cmd in ["object.envelope.resetWithWarp", "object.envelope.options"] { + for (p, want) in &calls { + refuses_with(&mut s, cmd, p, want); + } + } + assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed"); + s.execute("select.none", &json!({})).unwrap(); + for (p, want) in &calls { + refuses_with(&mut s, "object.envelope.options", p, want); + } + sel(&mut s, &[e]); + s.execute("object.envelope.resetWithWarp", &json!({"style": "warp.flag", "orientation": "vertical"})).unwrap(); + assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } + if matches!(&**kind, EnvelopeKind::Warp { style, horizontal: false, .. } if style == "flag"))); +} + +/// Envelope Options refuses a `preserveShape` that is not text, for the selected envelope and for +/// the options new envelopes get, and changes neither; null keeps the current value. +#[test] +fn envelope_options_refuse_a_preserve_shape_that_is_not_text() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap()); + let calls = [json!({"preserveShape": 1}), json!({"preserveShape": true, "antiAlias": false}), json!({"preserveShape": ["transparency"]})]; + for ids in [vec![e], vec![]] { + sel(&mut s, &ids); + let before = (node(&s, e), undo_len(&s)); + for p in &calls { + refuses_with(&mut s, "object.envelope.options", p, "`preserveShape` must be a string"); + } + assert_eq!((node(&s, e), undo_len(&s)), before, "nothing changed with {ids:?} selected"); + } + assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed"); + sel(&mut s, &[e]); + s.execute("object.envelope.options", &json!({"preserveShape": "transparency"})).unwrap(); + s.execute("object.envelope.options", &json!({"preserveShape": null, "antiAlias": false})).unwrap(); + assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { options, .. } + if options.preserve_shape == PreserveShape::Transparency && !options.anti_alias)); +} + #[test] fn envelope_renders_distorted() { let mut s = session(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 173303e9d..4e6100df9 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -257,10 +257,10 @@ fn tiles_under_squares() -> (Session, Vec, Vec) { } /// With 1,000 tiles selected under 50,000 squares, Lock › All Artwork Above locks every square in one -/// undo step and Hide › All Artwork Above hides every square, each within 20 s, and the tiles stay -/// selected. +/// undo step and Hide › All Artwork Above hides every square, each in a few walks of the tree, and +/// the tiles stay selected. #[test] -fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_finish_within_20_s() { +fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_take_few_tree_walks() { let (mut s, tiles, squares) = tiles_under_squares(); sel(&mut s, &tiles); let before = s.doc().unwrap().doc.clone(); @@ -280,9 +280,10 @@ fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_finish_within_ } /// With all 51,000 objects of [`tiles_under_squares`] selected, Lock › All Artwork Above and Hide › -/// All Artwork Above each find nothing to lock or hide within 20 s and record no undo step. +/// All Artwork Above each find nothing to lock or hide in a few walks of the tree and record no undo +/// step. #[test] -fn lock_and_hide_all_artwork_above_with_51000_objects_selected_finish_within_20_s() { +fn lock_and_hide_all_artwork_above_with_51000_objects_selected_take_few_tree_walks() { let (s, tiles, squares) = tiles_under_squares(); let (steps, all) = (undo_len(&s), [tiles, squares].concat()); assert!(selected(&s) == all, "everything is selected"); @@ -2086,6 +2087,63 @@ fn find_next_cycles_through_text_objects() { assert!(s.execute("edit.findNext", &json!({"find": "zzz"})).unwrap()["id"].is_null()); } +/// The Type menu's commands and Find and Replace and Find Next refuse a param of the wrong type, or +/// `ids` that name no object, with an error naming the param, and leave the text, the selection and +/// the undo history as they were, even when Insert Character is also given a source it would use +/// first. Null counts as a param left out, which keeps its default. +#[test] +fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() { + let mut s = session(); + let t = text(&mut s, "\"cat\" -- cat catalog..."); + let pid = s.execute("path.create", &json!({"d": "M100 300 L500 300"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "cat", "size": 12, "at": [300, 310]})).unwrap(); + let on_path = id_of(&r); + select_with_a_step_to_redo(&mut s, &[t, on_path]); + let before = (history(&s), plain(&s, t), plain(&s, on_path), selected(&s)); + let string = |k: &str| format!("`{k}` must be a string"); + let switch = |k: &str| format!("`{k}` must be true or false"); + let scope = "`scope` must be one of selection, document".to_string(); + let not_a_list = "ids must be an array of non-negative integer object ids".to_string(); + let not_an_id = "invalid object id \"x\": expected a non-negative integer".to_string(); + for (cmd, p, want) in [ + ("edit.findReplace", json!({"find": "cat", "replace": 5}), string("replace")), + ("edit.findReplace", json!({"find": 5, "replace": "dog"}), string("find")), + ("edit.findReplace", json!({"find": "CAT", "replace": "dog", "matchCase": "true"}), switch("matchCase")), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "wholeWord": "yes"}), switch("wholeWord")), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": t.0}), not_a_list), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, 999_999]}), "no such object id 999999".to_string()), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id), + ("edit.findNext", json!({"find": ["cat"]}), string("find")), + ("edit.findNext", json!({"find": "cat", "matchCase": 1}), switch("matchCase")), + ("edit.findNext", json!({"find": "cat", "wholeWord": "false"}), switch("wholeWord")), + ("type.smartPunctuation", json!({"dashes": "false"}), switch("dashes")), + ("type.smartPunctuation", json!({"ellipsis": 0}), switch("ellipsis")), + ("type.smartPunctuation", json!({"quotes": "no"}), switch("quotes")), + ("type.smartPunctuation", json!({"scope": 5}), scope.clone()), + ("type.smartPunctuation", json!({"scope": "page"}), scope), + ("type.changeCase", json!({"case": 5}), string("case")), + ("type.insert", json!({"char": 5}), string("char")), + ("type.insert", json!({"text": 5}), string("text")), + ("type.insert", json!({"unicode": 176}), string("unicode")), + ("type.insert", json!({"char": "bullet", "unicode": 176}), string("unicode")), + ("type.insert", json!({"text": "x", "code": "a"}), "`code` must be a whole number".to_string()), + ("type.insert", json!({"text": "x", "code": 1.5}), "`code` must be a whole number".to_string()), + ("type.pathOptions", json!({"flip": "true"}), switch("flip")), + ("type.pathOptions", json!({"effect": 1}), string("effect")), + ("type.pathOptions", json!({"alignToPath": true}), string("alignToPath")), + ("type.pathOptions", json!({"spacing": "4"}), "`spacing` must be a number, not \"4\"".to_string()), + ] { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); + assert_eq!((history(&s), plain(&s, t), plain(&s, on_path), selected(&s)), before, "{cmd} {p}"); + } + // `code: null` gives way to `unicode`, and null switches and ids take their defaults. + s.execute("type.insert", &json!({"code": null, "unicode": "00B0", "ids": [on_path.0]})).unwrap(); + assert_eq!(plain(&s, on_path), "cat°"); + let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog", "matchCase": null, "wholeWord": null, "ids": null})).unwrap(); + assert_eq!((r["count"].as_u64(), plain(&s, t), plain(&s, on_path)), (Some(4), "\"dog\" -- dog dogalog...".to_string(), "dog°".to_string())); +} + // ---------- Select ---------- #[test] diff --git a/crates/engine/src/tests/paintproxy.rs b/crates/engine/src/tests/paintproxy.rs index b64736b97..b270826e7 100644 --- a/crates/engine/src/tests/paintproxy.rs +++ b/crates/engine/src/tests/paintproxy.rs @@ -185,16 +185,53 @@ fn recent_colors_are_fed_by_every_paint_command() { s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); assert_eq!(hexes(&s).len(), 3); // The list holds the newest `RECENT_MAX` colours. - for i in 0..20u8 { + for i in 0..40u8 { s.execute("paint.setFill", &json!({"color": Color::rgb8(i, 0, 0).to_hex()})).unwrap(); } assert_eq!(s.recent_colors.len(), Session::RECENT_MAX); - assert_eq!(s.recent_colors[0].to_hex(), "#130000"); + assert_eq!(s.recent_colors[0].to_hex(), "#270000"); let r = s.execute("paint.recent", &json!({})).unwrap(); - assert_eq!(r["colors"][0]["hex"], "#130000"); + assert_eq!(r["colors"][0]["hex"], "#270000"); let _ = id; } +#[test] +fn recent_colors_keep_thirty_colors_across_documents() { + let mut s = session(); + rect(&mut s); + let red = |i: u8| Color::rgb8(i, 0, 0).to_hex(); + for i in 1..=30u8 { + s.execute("paint.setFill", &json!({"color": red(i)})).unwrap(); + } + assert_eq!(Session::RECENT_MAX, 30); + let all: Vec = (1..=30u8).rev().map(red).collect(); + assert_eq!(hexes(&s), all, "30 colors are kept, newest first"); + // The 31st drops the oldest. + s.execute("paint.setFill", &json!({"color": red(31)})).unwrap(); + assert_eq!(hexes(&s).len(), 30); + assert_eq!(hexes(&s)[0], red(31)); + assert_eq!(hexes(&s)[29], red(2)); + assert!(!hexes(&s).contains(&red(1)), "the oldest color is dropped"); + // Applying a color already in the list moves it to the front, once. + s.execute("paint.setStroke", &json!({"color": red(10)})).unwrap(); + assert_eq!(hexes(&s).len(), 30); + assert_eq!(hexes(&s)[0], red(10)); + assert_eq!(hexes(&s).iter().filter(|h| **h == red(10)).count(), 1); + let before = hexes(&s); + // A new document, and going back to the first one, keep the list. + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + assert_eq!(hexes(&s), before); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(hexes(&s), before); + // A color applied in the second document joins the same list. + s.execute("document.activate", &json!({"index": 1})).unwrap(); + rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(hexes(&s)[0], "#00ff00"); + assert_eq!(hexes(&s)[1..], before[..29]); +} + #[test] fn live_previews_remember_only_the_committed_colour() { let mut s = session(); diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 706f40940..0f95fc2f6 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -485,6 +485,11 @@ fn artboard_commands_refuse_a_boolean_or_number_given_as_text() { ("artboard.duplicate", "art", json!({"index": 0, "art": "false"})), ("artboard.copy", "art", json!({"index": 0, "art": "true"})), ("artboard.cut", "art", json!({"index": 1, "art": 0})), + ("artboard.move", "lockedAndHidden", json!({"index": 0, "dx": 10, "moveArt": true, "lockedAndHidden": "true"})), + ("artboard.duplicate", "lockedAndHidden", json!({"index": 0, "lockedAndHidden": "true"})), + ("artboard.copy", "lockedAndHidden", json!({"index": 0, "lockedAndHidden": 1})), + ("artboard.cut", "lockedAndHidden", json!({"index": 1, "lockedAndHidden": 0})), + ("artboard.rearrange", "lockedAndHidden", json!({"spacing": 100, "lockedAndHidden": "true"})), ] .into_iter() .map(|(cmd, key, p)| not_a_switch(cmd, p, key)) @@ -961,6 +966,86 @@ fn art_also_on_a_locked_artboard_stays_when_another_artboard_moves() { assert_eq!(ids(&moved["moved"]), [free.0, held.0], "with the preference on, it moves"); } +/// Two artboards with the Artboard tool on the second, which holds a plain, a locked and a hidden +/// rectangle, under prefs moveLockedWithArtboard `on`. +fn locked_art_session(on: bool) -> Session { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let mut shape = |x: u32| s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 40, "height": 20})).unwrap()["id"].clone(); + let (_, locked, hidden) = (shape(840), shape(900), shape(960)); + s.execute("object.lock", &json!({"ids": [locked]})).unwrap(); + s.execute("object.hide", &json!({"ids": [hidden]})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("artboard", ViewInfo::default()).unwrap(); + s.set_tool_option("selected", &json!([1])); + s.prefs.move_locked_with_artboard = on; + s +} + +/// The bounds of every object in the document, and how many objects the clipboard holds. +fn art_and_clipboard(s: &Session) -> (Vec>, usize) { + let mut art = vec![]; + for layer in &s.doc().unwrap().doc.layers { + layer.walk(&mut |n| { + if !n.is_layer() { + art.push(n.geometric_bounds()); + } + }); + } + (art, s.clipboard.nodes.len()) +} + +/// A command that takes art with an artboard journals whether locked and hidden art went along, so +/// a replay under the other preference takes the same art, in one undo step. +#[test] +fn artboard_commands_journal_the_locked_art_preference() { + let run = |on: bool, cmd: &str, p: &serde_json::Value| { + let mut s = locked_art_session(on); + s.execute(cmd, p).unwrap(); + (art_and_clipboard(&s), s.journal.last().unwrap().clone()) + }; + let mut differ = vec![]; + for (cmd, p) in [ + ("artboard.duplicate", json!({"index": 1, "art": true})), + ("artboard.copy", json!({"index": 1, "art": true})), + ("artboard.cut", json!({"index": 1, "art": true})), + ("artboard.move", json!({"index": 1, "dx": 0, "dy": 1000, "moveArt": true})), + ("artboard.move", json!({"index": 1, "dx": 0, "dy": 1000, "moveArt": true, "copy": true})), + ("artboard.rearrange", json!({"layout": "row", "spacing": 100})), + ("edit.copy", json!({})), + ("edit.cut", json!({})), + // A null reads as a key left out. + ("artboard.duplicate", json!({"index": 1, "art": true, "lockedAndHidden": null})), + ("artboard.rearrange", json!({"layout": "row", "spacing": 100, "lockedAndHidden": null})), + ("edit.cut", json!({"lockedAndHidden": null})), + ] { + let (with, (id_with, p_with)) = run(true, cmd, &p); + let (without, (id_without, p_without)) = run(false, cmd, &p); + assert_ne!(with, without, "{cmd} {p}: an omitted param reads the preference"); + if run(false, &id_with, &p_with).0 != with { + differ.push(format!("{cmd} {p}, replayed with the preference off")); + } + if run(true, &id_without, &p_without).0 != without { + differ.push(format!("{cmd} {p}, replayed with the preference on")); + } + + let mut s = locked_art_session(true); + let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + s.execute(cmd, &p).unwrap(); + if !cmd.ends_with("copy") { + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{cmd} {p}: one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, doc, "{cmd} {p}: undone"); + } + } + assert!(differ.is_empty(), "{differ:#?}"); + // With the Artboard tool chosen, a bad value is refused as it is by the command run. + let mut s = locked_art_session(false); + let calls: Vec<_> = + ["edit.copy", "edit.cut"].map(|cmd| crate::tests::not_a_switch(cmd, json!({"lockedAndHidden": "true"}), "lockedAndHidden")).to_vec(); + assert_eq!(crate::tests::not_refused(&mut s, &calls), Vec::::new()); +} + /// A locked artboard keeps its place and size: `artboard.setProps` refuses a new rectangle but /// still renames it, `artboard.move`, `artboard.delete`, `artboard.cut` and `artboard.fitToArt` /// refuse it, and Rearrange leaves it and the art on it where they are. A copy of it starts @@ -1621,6 +1706,141 @@ fn delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents( assert!(enabled_count > 5 && enabled_count < 55, "both answers come up: {enabled_count} of 60 enabled"); } +/// Delete Empty Artboards' enabled state, as a menu asks for it, and how many times asking measured +/// a document on this thread. +fn delete_empty_enabled_counted(s: &Session) -> (bool, usize) { + let spec = crate::cmd::command_specs().iter().find(|c| c.id == "artboard.deleteEmpty").unwrap(); + let measures = || crate::doc_state::EMPTY_ARTBOARD_MEASURES.with(std::cell::Cell::get); + let before = measures(); + let enabled = (spec.enabled)(s).is_ok(); + (enabled, measures() - before) +} + +/// A menu asks on every frame while it is open: the first frame measures the document, and the +/// others, a change of the selection among them, do not. +#[test] +fn delete_empty_artboards_check_measures_a_document_once() { + let mut s = row_of_artboards(2); + let art = box_at(&mut s, 10.0, 20.0); + assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "the first frame"); + for _ in 0..10 { + assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "a later frame"); + } + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after a change of the selection"); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after another change of the selection"); +} + +/// The enabled state follows an edit, an undo, a redo, an added and a removed artboard, a locked +/// artboard, and each change measures the document once. +#[test] +fn delete_empty_artboards_check_follows_each_change_to_the_document() { + let mut s = row_of_artboards(2); + box_at(&mut s, 10.0, 20.0); + assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "artboard 2 is empty"); + + let on_second = box_at(&mut s, 410.0, 20.0); + assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "an edit puts art on artboard 2"); + assert_eq!(delete_empty_enabled_counted(&s), (false, 0)); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "Undo takes the art off artboard 2"); + assert_eq!(delete_empty_enabled_counted(&s), (true, 0)); + + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "Redo puts it back"); + + s.execute("select.set", &json!({"ids": [on_second.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "an edit moves the art off artboard 2"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (false, 1)); + + s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 200, "height": 150})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "a new artboard is empty"); + + s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "a locked artboard stays"); + s.execute("artboard.setProps", &json!({"index": 2, "locked": false})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "unlocked, it can go"); + + s.execute("artboard.delete", &json!({"index": 2})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "with the empty artboard removed, none is left"); + assert_eq!(delete_empty_enabled_counted(&s), (false, 0)); +} + +/// A change made to the document directly, with nothing else holding it and no new revision, is +/// measured again too. +#[test] +fn delete_empty_artboards_check_follows_a_change_in_place() { + let mut s = row_of_artboards(2); + box_at(&mut s, 10.0, 20.0); + assert_eq!(delete_empty_enabled_counted(&s), (true, 1)); + let st = s.doc_mut().unwrap(); + assert_eq!(Arc::strong_count(&st.doc), 1, "the undo history holds the document before the edit"); + let revision = st.revision; + Arc::make_mut(&mut st.doc).artboards.truncate(1); + assert_eq!(st.revision, revision); + assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "one artboard left"); +} + +/// Each open document has its own answer: switching documents gives the other's without measuring +/// again, and an edit to one leaves the other's alone. +#[test] +fn delete_empty_artboards_check_follows_a_document_switch() { + let mut s = row_of_artboards(2); + assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "two empty artboards: the second can go"); + s.execute("file.new", &json!({"width": 200, "height": 150})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (false, 1), "the new document has one artboard"); + assert!(s.set_active(0)); + assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "the first document's answer stands"); + assert!(s.set_active(1)); + assert_eq!(delete_empty_enabled_counted(&s), (false, 0), "the second document's answer stands"); + s.execute("artboard.new", &json!({"x": 400, "y": 0, "width": 200, "height": 150})).unwrap(); + assert_eq!(delete_empty_enabled_counted(&s), (true, 1), "the second document has an empty artboard now"); + assert!(s.set_active(0)); + assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "the edit to the second document leaves the first's answer"); +} + +/// On documents made from a fixed seed, after each of a run of changes, the enabled state is what +/// measuring every artboard of the document gives. +#[test] +fn delete_empty_artboards_check_matches_measuring_after_varied_changes() { + let mut seed: u64 = 0x0de1_e7e0_e3b7; + let mut next = |n: u64| { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (seed >> 33) % n + }; + let mut answers = [0usize; 2]; + let mut measured = 0; + for _ in 0..30 { + let mut s = row_of_artboards(1 + next(3) as usize); + for _ in 0..12 { + let artboards = s.doc().unwrap().doc.artboards.len(); + let index = next(artboards as u64); + // A change can be refused (nothing to undo, a locked artboard): the state still has to match. + let _ = match next(7) { + 0 => s.execute("shape.rectangle", &json!({"x": next(1200) as f64 - 50.0, "y": 10, "width": 20, "height": 20})), + 1 => s.execute("edit.undo", &json!({})), + 2 => s.execute("edit.redo", &json!({})), + 3 => s.execute("artboard.new", &json!({"x": 400 * (artboards + 1), "y": 0, "width": 200, "height": 150})), + 4 => s.execute("artboard.delete", &json!({"index": index})), + 5 => s.execute("artboard.setProps", &json!({"index": index, "locked": next(2) == 0})), + _ => s.execute("select.all", &json!({})), + }; + let (enabled, count) = delete_empty_enabled_counted(&s); + measured += count; + answers[usize::from(enabled)] += 1; + let d = &s.doc().unwrap().doc; + assert_eq!(enabled, d.artboards.len() > 1 && d.artboards.iter().any(|a| !a.locked && !d.artboard_has_art(a.rect))); + assert_eq!(delete_empty_enabled_counted(&s), (enabled, 0), "a second frame"); + } + } + assert!(answers[0] > 30 && answers[1] > 30, "both answers come up: {answers:?}"); + assert!(measured < 360, "changes of the selection alone do not measure: {measured} of 360"); +} + /// On paths and compound paths made from a fixed seed (curves, open and closed, with strokes of /// every join, cap and alignment, miter limits, arrowheads, width profiles and dashes), an /// artboard has art exactly when an object's visual bounds meet it, for artboards just inside, diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 743f81494..7616a4529 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -488,6 +488,35 @@ fn relink_points_a_placed_document_at_another_file() { assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); } +/// Missing Links from This Folder finds a missing placed document by name, except with copy, which +/// takes images only: the placed document then stays as it is. +#[test] +fn relink_missing_in_folder_finds_placed_documents_except_with_copy() { + let dir = Folder::new("placed-missing-folder"); + let (doc_file, pic) = (dir.file("art/a.vectorcraft"), dir.file("art/photo.png")); + write(&pic, &crate::tests_links::png(60, 30, RED)); + source(&doc_file, 100.0, 50.0, RED, 0); + let mut s = session(); + let (placed_id, image_id) = (place(&mut s, &doc_file), place(&mut s, &pic)); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + std::fs::remove_file(&doc_file).unwrap(); + std::fs::remove_file(&pic).unwrap(); + open(&mut s, &doc_path); + let found = dir.0.join("found"); + let chosen = found.join("renamed.png").to_string_lossy().into_owned(); + write(&chosen, &crate::tests_links::png(60, 30, BLUE)); + source(&found.join("a.vectorcraft").to_string_lossy(), 100.0, 50.0, BLUE, 0); + let p = json!({"ids": [image_id.0], "path": chosen, "scope": "missingInFolder", "copy": true}); + let r = s.execute("links.relink", &p).unwrap(); + assert_eq!(r["relinked"], json!([image_id.0]), "{r}"); + assert_eq!(placed(&s, placed_id).link.path, doc_file, "copy leaves the placed document"); + s.execute("edit.undo", &json!({})).unwrap(); + let p = json!({"ids": [image_id.0], "path": chosen, "scope": "missingInFolder"}); + let r = s.execute("links.relink", &p).unwrap(); + assert_eq!(r["relinked"], json!([image_id.0, placed_id.0]), "{r}"); +} + /// A file made from scratch at `path` in `format` (`pdf`, or `ai`: a PDF-compatible .ai): one /// 100×50 page per color of `pages`, filled with it (`extra` more rectangles on each change the /// file's size). @@ -891,6 +920,36 @@ fn saving_a_document_refreshes_the_open_documents_that_place_it() { assert_eq!(s.active_index(), Some(child), "the saved document stays active"); } +/// Placed documents inside a symbol and a pattern of another open document are read again when +/// their file is saved, as one in the layers is. +#[test] +fn saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns() { + let dir = Folder::new("placed-refresh-nested"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + s.execute("symbol.new", &json!({"name": "Logo", "ids": [id.0]})).unwrap(); + let id = place(&mut s, &src); + s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let parent = s.active_index().unwrap(); + let mut in_layers = 0; + s.doc().unwrap().doc.walk(|n| in_layers += usize::from(matches!(n.kind, NodeKind::PlacedDocument(_)))); + assert_eq!(in_layers, 0, "no placed document in the layers"); + s.execute("document.open", &json!({"path": src})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#1414e6"})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + let hash = Some(vectorcraft_doc::links::hash_bytes(&std::fs::read(&src).unwrap())); + let doc = &s.documents()[parent]; + assert_eq!(doc.history.undo.last().unwrap().label, "Update Links"); + assert!(near(centre_colour(&doc.doc), BLUE), "the symbol's instance shows the saved version"); + let mut hashes = vec![]; + doc.doc.visit_placed(|_, p| hashes.push(p.link.hash.clone())); + assert_eq!(hashes, [hash.clone(), hash], "the symbol's, then the pattern's"); +} + #[test] fn link_info_gives_the_artboards_size_not_pixels() { let dir = Folder::new("placed-info"); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 194c56178..fdfe60067 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -523,6 +523,301 @@ fn invalid_install_flags_are_refused() { assert!(vectorcraft_plugins::registry::get(light).is_none()); } +/// A test filter `id` (named "Test `id`") whose manifest lists `permissions` (a JSON list) and that +/// returns `output`. +fn asking(id: &str, permissions: &str, output: &Value) -> Vec { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"2.1","kind":"filter","permissions":{permissions}}}"#); + wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap() +} + +/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`), +/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's +/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same +/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the +/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies. +#[test] +fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { + let mut s = session(); + let id = "test.engine.permissions.install"; + let empty = json!({"objects": []}); + let data = |perms: &str| vectorcraft_format::base64_encode(&asking(id, perms, &empty)); + let reads = data(r#"["doc.read"]"#); + let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": reads})); + assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read"]))); + let hash = vectorcraft_plugins::permissions::sha256_hex(&asking(id, r#"["doc.read"]"#, &empty)); + assert_eq!(inspected["hash"], hash); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing"); + // Refused without a grant that holds every permission it lists, naming them. + for (grant, says) in [ + (Value::Null, "asks for the permissions doc.read"), + (json!([]), "asks for the permissions doc.read"), + (json!(["doc.write"]), "asks for the permissions doc.read"), + (json!(["doc.read", "net.fetch"]), "unknown permission `net.fetch`"), + (json!("doc.read"), "`grant` must be a list"), + (json!([1]), "`grant` must be a list"), + ] { + let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": grant})).unwrap_err().to_string(); + assert!(e.contains(says), "grant {grant}: {e}"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with grant {grant}"); + } + // `hash` pins the module the user was shown. + for wrong in [json!("0".repeat(64)), json!(1)] { + let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": ["doc.read"], "hash": wrong})).unwrap_err().to_string(); + assert!(e.contains("SHA-256 `hash`"), "hash {wrong}: {e}"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with hash {wrong}"); + } + let installed = run(&mut s, "plugin.install", json!({"dataBase64": reads, "grant": ["doc.read"], "hash": hash})); + assert_eq!(installed["permissions"], json!(["doc.read"])); + let g = vectorcraft_plugins::registry::granted(id).unwrap(); + assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..])); + // Replaced with another module that lists the same permissions: no new grant needed, and the + // grant now records the new module's hash. + let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)})); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other)); + // One that asks for more is refused until it is granted all of them. + let more = data(r#"["doc.read","doc.write"]"#); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true); + let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string(); + assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}"); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays"); + run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]})); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded"); + // Removed, it is asked again. + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(vectorcraft_plugins::registry::granted(id).is_none()); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsApproval"], true); + // A plug-in without `permissions` installs as before, with its kind's. + let plain = install(&mut s, DESATURATE); + assert_eq!(plain["permissions"], json!(["doc.read", "doc.write", "state"])); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false); +} + +/// The host enforces what a filter was granted: without `doc.write` an output that changes the +/// document is refused and nothing changes, without `doc.read` it is given no objects, and without +/// `state` it is given no data kept with the document and may not write any. +#[test] +fn a_filter_does_only_what_its_permissions_allow() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]})); + let grant = |s: &mut Session, wasm: &[u8], perms: Value| { + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms})); + }; + // Denied `doc.write`: renaming the object is refused, and the document and history stay. + let reader = "test.engine.permissions.reader"; + grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0, "name": "Changed"}]})), json!(["doc.read"])); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = filter(&mut s, reader).unwrap_err().to_string(); + assert!(e.contains("wasn't granted `doc.write`"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + // Deleting it (leaving it out of the output) and adding objects are changes too. + for out in [json!({"objects": []}), json!({"objects": [{"id": id.0}, {"path": triangle()}]})] { + grant(&mut s, &asking(reader, r#"["doc.read"]"#, &out), json!(["doc.read"])); + assert!(filter(&mut s, reader).is_err(), "{out}"); + assert_eq!(s.doc().unwrap().doc, before, "{out}"); + } + // Returning the object unchanged changes nothing, so it may. + grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0}]})), json!(["doc.read"])); + filter(&mut s, reader).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before.layers); + // Granted `doc.write` alone: it is given no objects, so its triangle is added beside the + // rectangle rather than replacing it. + let writer = "test.engine.permissions.writer"; + let manifest = format!(r#"{{"id":"{writer}","name":"W","kind":"filter","permissions":["doc.write"]}}"#); + let added = json!({"objects": [{"path": triangle(), "name": "Given nothing"}]}); + let src = tpl::when_given(&manifest, tpl::Given::Input, r#"{"objects":[]}"#, &added.to_string()); + grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.write"])); + let made = filter(&mut s, writer).unwrap(); + let new = NodeId(made["ids"][0].as_u64().unwrap()); + assert_eq!(node(&s, new).name.as_deref(), Some("Given nothing")); + assert!(s.doc().unwrap().doc.node(id).is_some(), "the rectangle it wasn't given stays"); + // Without `state`: kept data is not handed over, and writing some is refused. + run( + &mut s, + "plugin.install", + json!({"dataBase64": vectorcraft_format::base64_encode(&asking("test.engine.permissions.keeper", "null", &json!({"objects": [{"id": id.0}], "documentData": {"pen": 1}})))}), + ); + filter(&mut s, "test.engine.permissions.keeper").unwrap(); + let stateless = "test.engine.permissions.keeper"; + let manifest = format!(r#"{{"id":"{stateless}","name":"K","kind":"filter","permissions":["doc.read","doc.write"]}}"#); + let src = + tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":null"#, &json!({"objects": [{"id": id.0, "name": "No data"}]}).to_string()); + grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.read", "doc.write"])); + filter(&mut s, stateless).unwrap(); + assert_eq!(node(&s, id).name.as_deref(), Some("No data"), "given no kept data"); + let before = s.doc().unwrap().doc.clone(); + grant( + &mut s, + &asking(stateless, r#"["doc.read","doc.write"]"#, &json!({"objects": [{"id": id.0}], "documentData": {"pen": 2}})), + json!(["doc.read", "doc.write"]), + ); + let e = filter(&mut s, stateless).unwrap_err().to_string(); + assert!(e.contains("wasn't granted `state`"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1}))); +} + +/// What the user granted is kept in the plug-in store with the module: the next session installs +/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is +/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder +/// until it is granted on its own. +#[test] +fn granted_permissions_are_kept_and_folders_skip_the_rest() { + let folder = crate::tests_links::Folder::new("plugin-store-grants"); + let id = "test.engine.permissions.stored"; + let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + assert!(folder.0.join("grants.json").is_file()); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store, as granted"); + // Only a store's own grants count: the same module in another store without a grant is + // skipped, and a grant given elsewhere is not written to that store. + let other = crate::tests_links::Folder::new("plugin-store-grants-other"); + std::fs::write(other.0.join("copy.wasm"), &wasm).unwrap(); + vectorcraft_plugins::registry::remove(id); + let mut elsewhere = session(); + elsewhere.attach_plugin_store(other.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "granted in another store only"); + let e = elsewhere.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "a grant kept in another store doesn't count here: {e}"); + let lone = "test.engine.permissions.lone"; + let lone_wasm = asking(lone, r#"["doc.read"]"#, &json!({"objects": []})); + run(&mut elsewhere, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&lone_wasm), "grant": ["doc.read"]})); + let kept = std::fs::read_to_string(other.0.join("grants.json")).unwrap(); + assert!(kept.contains(lone) && !kept.contains(id), "{kept}"); + run(&mut elsewhere, "plugin.remove", json!({"id": lone})); + assert!(!other.0.join("grants.json").exists(), "the last grant removed takes the file with it"); + // Without the grant it is skipped, and the file stays. + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + std::fs::remove_file(folder.0.join("grants.json")).unwrap(); + let mut third = session(); + third.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "nobody granted it"); + assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "the module stays in the store"); + + // The Additional Plug-ins Folder skips it with a note until it is granted. + let dir = crate::tests_links::Folder::new("plugin-folder-grants"); + std::fs::write(dir.0.join("asks.wasm"), &wasm).unwrap(); + let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([])); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}"); + run(&mut third, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([id])); + run(&mut third, "plugin.remove", json!({"id": id})); + // With a store attached, only that store's grants load it from the folder: a grant given in a + // session without a store doesn't count. + let mut storeless = session(); + run(&mut storeless, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + vectorcraft_plugins::registry::remove(id); + assert!(vectorcraft_plugins::registry::granted(id).is_some(), "granted in the process"); + let empty = crate::tests_links::Folder::new("plugin-store-grants-empty"); + let mut scoped = session(); + scoped.attach_plugin_store(empty.0.clone()); + let r = run(&mut scoped, "plugin.reload", json!({"path": dir.0.display().to_string()})); + vectorcraft_plugins::registry::revoke(id); + assert_eq!(r["loaded"], json!([]), "a grant outside this store doesn't load it"); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval"), "{why}"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "not installed"); + // The preference's folder is loaded again for a session whose store holds other grants. + let pref_dir = crate::tests_links::Folder::new("plugin-folder-pref-grants"); + std::fs::write(pref_dir.0.join("asks.wasm"), &wasm).unwrap(); + let pref = json!({"key": "pluginsFolder", "value": pref_dir.0.display().to_string()}); + run(&mut scoped, "prefs.set", pref.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "nothing granted in this store"); + let granting = crate::tests_links::Folder::new("plugin-store-grants-pref"); + let mut granted = session(); + granted.attach_plugin_store(granting.0.clone()); + run(&mut granted, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + run(&mut granted, "prefs.set", pref); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "loaded under this store's grant"); + run(&mut granted, "plugin.remove", json!({"id": id})); +} + +/// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is +/// skipped without dropping the other grants, and recording a grant writes it back as it was, +/// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a +/// list is never overwritten. +#[test] +fn grants_this_version_cant_read_are_kept() { + let folder = crate::tests_links::Folder::new("plugin-store-grants-unknown"); + let kept = "test.engine.permissions.kept"; + let added = "test.engine.permissions.added"; + let later = "test.engine.permissions.later"; + let empty = json!({"objects": []}); + let kept_wasm = asking(kept, r#"["doc.read"]"#, &empty); + let added_wasm = asking(added, r#"["doc.read"]"#, &empty); + let later_wasm = asking(later, r#"["doc.read"]"#, &empty); + let future = json!({"id": later, "hash": "00", "permissions": ["doc.read", "ui.panel"]}); + let invalid = json!({"id": "bad id/", "hash": "00", "permissions": ["doc.read"]}); + let grant = json!({"id": kept, "hash": vectorcraft_plugins::permissions::sha256_hex(&kept_wasm), "permissions": ["doc.read"]}); + let grants = folder.0.join("grants.json"); + std::fs::write(&grants, serde_json::to_vec(&json!([future, grant, invalid])).unwrap()).unwrap(); + std::fs::write(folder.0.join("kept.wasm"), &kept_wasm).unwrap(); + let entries = || serde_json::from_slice::>(&std::fs::read(&grants).unwrap()).unwrap(); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(kept).is_some(), "the entries this version can't read don't drop the grant next to them"); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]})); + let now = entries(); + assert_eq!(now.len(), 4, "{now:?}"); + assert!(now.contains(&future) && now.contains(&invalid), "written back as they were: {now:?}"); + assert!(now.iter().any(|e| e["id"] == added), "{now:?}"); + run(&mut s, "plugin.remove", json!({"id": added})); + assert_eq!(entries().len(), 3); + // Granting the plug-in an entry names replaces that entry. + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&later_wasm), "grant": ["doc.read"]})); + let now = entries(); + assert!(!now.contains(&future), "{now:?}"); + assert_eq!(now.iter().filter(|e| e["id"] == later).count(), 1, "{now:?}"); + run(&mut s, "plugin.remove", json!({"id": later})); + run(&mut s, "plugin.remove", json!({"id": kept})); + assert_eq!(entries(), vec![invalid.clone()], "the last usable grant removed leaves the entry it can't read"); + + // A file that doesn't hold a list is left as it is: installing still works in this session. + for broken in [&b"[{\"id\": "[..], &b"{\"id\": \"x\"}"[..]] { + std::fs::write(&grants, broken).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]})); + assert!(vectorcraft_plugins::registry::get(added).is_some()); + assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten"); + run(&mut s, "plugin.remove", json!({"id": added})); + assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten by a removal either"); + } + + // The file stays within the 1 MiB it is read up to: compact when pretty-printing would pass it, + // and left as it is when even compact would. + let cap = 1 << 20; + let many: Vec = (0..14_000).map(|i| json!({"id": format!("x.{i}"), "hash": "00", "permissions": ["ui.panel"]})).collect(); + let compact = serde_json::to_vec(&many).unwrap(); + assert!(compact.len() < cap && serde_json::to_vec_pretty(&many).unwrap().len() > cap); + std::fs::write(&grants, &compact).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]})); + let written = std::fs::read(&grants).unwrap(); + assert!(written.len() <= cap, "{}", written.len()); + assert_eq!(entries().len(), many.len() + 1); + let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm)})); + assert_eq!(inspected["needsApproval"], json!(false), "the compact file is read back"); + run(&mut s, "plugin.remove", json!({"id": added})); + let full = serde_json::to_vec(&json!([{"id": "x.full", "hash": "0".repeat(cap - 100), "permissions": ["ui.panel"]}])).unwrap(); + std::fs::write(&grants, &full).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]})); + assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap"); + run(&mut s, "plugin.remove", json!({"id": added})); +} + /// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next /// start without asking, with its budget. #[test] @@ -616,6 +911,35 @@ fn a_filter_run_made_elsewhere_keeps_its_document_data() { assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects"); } +/// A plug-in of API level 2 that uses the host imports lists `progress`, which the user grants like +/// any permission; a filter run made elsewhere then reports its progress, as a share, to the +/// progress the caller shows, and sees the caller's Cancel. +#[test] +fn a_level_2_filter_installs_with_progress_granted_and_reports_to_the_caller() { + use crate::cmd::fileio::progress::{self, Progress}; + use crate::cmd::plugin::FilterRun; + let mut s = session(); + let id = "test.engine.level2"; + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}}"#); + let wasm = wat::parse_str(tpl::cooperating(&manifest, 1000, r#"{"objects":[]}"#, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&wasm); + let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data})); + assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read", "doc.write", "progress"]))); + let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read", "doc.write"]})).unwrap_err().to_string(); + assert!(e.contains("asks for the permissions doc.read, doc.write, progress"), "{e}"); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write", "progress"]})); + let shown = Progress::new(); + let r = FilterRun::prepare(&s, &json!({"id": id})).unwrap(); + assert!(progress::scope(&shown, || r.run()).is_ok()); + assert_eq!((shown.counts(), shown.counts_share()), ((999, 1000), true)); + // Cancelled, it stops on its first step, and what it returns then is discarded. + shown.cancel(); + let cancelled = progress::scope(&shown, || r.run()); + assert!(matches!(cancelled, Err(EngineError::Cancelled)), "{cancelled:?}"); + assert_eq!(shown.counts(), (0, 1000)); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between /// two slices of its budget and changes nothing. #[test] diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 4b5848bcf..437701136 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -814,6 +814,37 @@ fn invoke_distance_guides_measures_the_selection_to_the_object_under_alt() { assert!(shown(&mut s, v).is_empty(), "Invoke Distance Guides off"); } +/// Smart Guides › Snap to Last Location (off by default): on, a square dragged 6 pt right and 5 pt +/// down with a 10 px Snapping Tolerance goes back to where it was; dragged 12 pt away, or with the +/// option or View › Smart Guides off, it lands where it is dragged. A value that is not a boolean +/// is refused. +#[test] +fn snap_to_last_location_puts_a_dragged_square_back() { + use vectorcraft_tools::PointerKind::{Down, Drag, Up}; + let mut s = new_doc(); + // Its edges and center stay out of reach of the 400 pt artboard's. + let m = square(&mut s, 100.0, 250.0); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let drag = |s: &mut Session, v: ViewInfo, to: (f64, f64)| { + gesture(s, v, &[(Down, 125.0, 275.0), (Drag, 140.0, 290.0), (Drag, to.0, to.1), (Up, to.0, to.1)]); + let at = top_left(s, m); + if at != (100.0, 250.0) { + s.execute("edit.undo", &json!({})).unwrap(); + } + at + }; + set_pref(&mut s, "snappingTolerance", json!(10)); + assert_eq!(drag(&mut s, v, (131.0, 280.0)), (106.0, 255.0), "off by default"); + set_pref(&mut s, "snapToLastLocation", json!(true)); + assert_eq!(drag(&mut s, v, (131.0, 280.0)), (100.0, 250.0), "7.8 pt away: back where it was"); + assert_eq!(drag(&mut s, v, (134.0, 283.0)), (109.0, 258.0), "12 pt away"); + assert_eq!(drag(&mut s, ViewInfo { smart_guides: false, ..v }, (131.0, 280.0)), (106.0, 255.0), "View › Smart Guides off"); + let refused = s.execute("prefs.set", &json!({"key": "snapToLastLocation", "value": "yes"})); + assert!(refused.is_err(), "{refused:?}"); + assert_eq!(s.execute("prefs.get", &json!({"key": "snapToLastLocation"})).unwrap(), json!(true), "unchanged"); +} + /// Preferences › Smart Guides (#394): Color, Alignment Guides, Anchor/Path Labels, Measurement /// Labels, Transform Tools and Snapping Tolerance change what the Selection tool shows while a /// square is dragged into line with another, and how far the pull reaches; a hidden guide still @@ -1936,6 +1967,315 @@ fn real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline() { assert!(moved.0 > 1.0 && moved.1 > 1.0 && moved.2.abs() < 1e-6 && moved.3.abs() < 1e-6, "the bounds moved with the drag: {moved:?}"); } +/// The bounding boxes (left, top, width) of the Real-time Drawing off feedback after a drag by (30, 40) +/// from (x, y), left to right. +fn dragged_feedback_boxes(s: &mut Session, x: f64, y: f64) -> Vec<(f64, f64, f64)> { + use vectorcraft_tools::Overlay; + use vectorcraft_tools::draw2::FEEDBACK; + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + set_pref(s, "realTimeDrawing", json!(false)); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, x + 30.0, y + 40.0), v).unwrap(); + let mut boxes: Vec<_> = s + .overlays(v) + .into_iter() + .filter_map(|o| match o { + Overlay::Path { path, color, .. } if color == FEEDBACK => Some(kurbo::Shape::bounding_box(&path)), + _ => None, + }) + .map(|b| ((b.x0 * 1e3).round() / 1e3, (b.y0 * 1e3).round() / 1e3, (b.width() * 1e3).round() / 1e3)) + .collect(); + boxes.sort_by(|p, q| p.0.total_cmp(&q.0)); + boxes +} + +/// The number of subpaths in each path of the Real-time Drawing off feedback the session shows now. +fn feedback_subpaths(s: &mut Session) -> Vec { + use vectorcraft_tools::Overlay; + use vectorcraft_tools::draw2::FEEDBACK; + s.overlays(ViewInfo::default()) + .into_iter() + .filter_map(|o| match o { + Overlay::Path { path, color, .. } if color == FEEDBACK => { + Some(path.elements().iter().filter(|el| matches!(el, kurbo::PathEl::MoveTo(_))).count()) + } + _ => None, + }) + .collect() +} + +/// With Real-time Drawing off, a dragged group draws the outline of each member, nested groups' +/// members included, at the dragged position instead of one union of them all. +#[test] +fn real_time_drawing_off_outlines_a_dragged_group_member_by_member() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let rect = |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let (a, b, c) = (rect(&mut s, 100.0), rect(&mut s, 150.0), rect(&mut s, 200.0)); + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + let inner = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, inner]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(130.0, 140.0, 100.0), (180.0, 140.0, 100.0), (230.0, 140.0, 100.0)], "one moved outline per member"); +} + +/// With Real-time Drawing off, a hidden member of a dragged group draws no outline. +#[test] +fn real_time_drawing_off_skips_a_hidden_member_of_a_dragged_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let rect = |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let (a, b, c) = (rect(&mut s, 100.0), rect(&mut s, 150.0), rect(&mut s, 200.0)); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(130.0, 140.0, 100.0), (230.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, a dragged blend draws the outline of each of its objects and steps. +#[test] +fn real_time_drawing_off_outlines_a_dragged_blend_step_by_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.blend.make", &json!({"steps": 3})).unwrap(); + let lefts: Vec = dragged_feedback_boxes(&mut s, 120.0, 120.0).iter().map(|b| b.0).collect(); + assert_eq!(lefts, [130.0, 180.0, 230.0, 280.0, 330.0], "one moved outline per blended object and step"); +} + +/// With Real-time Drawing off, a dragged compound shape draws the outline of the area it makes. +#[test] +fn real_time_drawing_off_outlines_a_dragged_compound_shape_by_its_area() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let shape = s.execute("object.compoundShape.make", &json!({"mode": "intersect"})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [shape]})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 170.0, 120.0), [(180.0, 140.0, 50.0)]); +} + +/// With Real-time Drawing off, a dragged group with a Pathfinder effect draws the outline of the area +/// the effect makes. +#[test] +fn real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("effect.apply", &json!({"effect": "pathfinder.intersect"})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 170.0, 120.0), [(180.0, 140.0, 50.0)]); +} + +/// With Real-time Drawing off, a dragged group with a Drop Shadow draws the outline of each member +/// instead of one union of them. +#[test] +fn real_time_drawing_off_outlines_a_dragged_group_with_an_effect_member_by_member() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 120.0, 120.0), [(130.0, 140.0, 100.0), (180.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, a dragged clip group draws only its clipping path, as its selection +/// highlight does. +#[test] +fn real_time_drawing_off_outlines_a_dragged_clip_group_by_its_clipping_path() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let art = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].clone(); + let clip = s.execute("shape.rectangle", &json!({"x": 120, "y": 110, "width": 50, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [art, clip]})).unwrap(); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 140.0, 130.0), [(150.0, 150.0, 50.0)]); +} + +/// With Real-time Drawing off, a dragged envelope draws the outline of each object it evaluates to: each +/// rectangle's fill and its outlined stroke. +#[test] +fn real_time_drawing_off_outlines_a_dragged_envelope_object_by_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(129.5, 139.5, 101.0), (130.0, 140.0, 100.0), (179.5, 139.5, 101.0), (180.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, a dragged Shaper group draws the outline of its result and not of the +/// source art it keeps. +#[test] +fn real_time_drawing_off_skips_the_source_art_of_a_dragged_shaper_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + let points = [[125.0, 120.0], [175.0, 135.0], [130.0, 150.0], [225.0, 165.0], [175.0, 180.0], [225.0, 185.0]]; + let shaper = s.execute("shaper.scribble", &json!({"points": points})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [shaper]})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(130.0, 140.0, 150.0), (130.0, 140.0, 150.0)], "the merged result's fill and stroke"); +} + +/// With Real-time Drawing off, a dragged symbol instance draws the bounds its selection highlight +/// shows, alone and as a member of a group. +#[test] +fn real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 120.0, 120.0), [(130.0, 140.0, 100.0)]); + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 160.0), ViewInfo::default()).unwrap(); + s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 50, "height": 50})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 150.0, 160.0), [(160.0, 180.0, 100.0), (430.0, 140.0, 50.0)]); +} + +/// With Real-time Drawing off, a handle resize of a group draws each member's outline at its new size. +#[test] +fn real_time_drawing_off_outlines_a_resized_group_member_by_member() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + // The Right handle, dragged 30 pt out: the group goes from 300 pt wide to 330. + assert_eq!(dragged_feedback_boxes(&mut s, 400.0, 125.0), [(100.0, 100.0, 110.0), (320.0, 100.0, 110.0)]); +} + +/// With Real-time Drawing off, each copy of a gradient mesh in a dragged repeat draws its mesh lines +/// once, without the pieces the mesh is drawn with. +#[test] +fn real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("object.repeat.grid", &json!({"rows": 1, "cols": 2})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes.len(), 2, "one outline per copy: {boxes:?}"); + assert_eq!(boxes[0], (130.0, 140.0, 100.0), "the first copy's mesh lines, moved"); + let subpaths = feedback_subpaths(&mut s); + assert!(subpaths.len() == 2 && subpaths.iter().all(|&n| n > 1), "each copy's mesh lines, not one boundary: {subpaths:?}"); +} + +/// With Real-time Drawing off, a dragged gradient mesh with a Drop Shadow draws its mesh lines as one +/// outline, without the pieces the mesh is drawn with. +#[test] +fn real_time_drawing_off_outlines_a_dragged_mesh_with_an_effect_by_its_lines() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 120.0, 120.0), [(130.0, 140.0, 100.0)]); + let subpaths = feedback_subpaths(&mut s); + assert!(matches!(subpaths.as_slice(), [n] if *n > 1), "the mesh's lines, not one boundary: {subpaths:?}"); +} + +/// With Real-time Drawing off, a dragged group with a Vector Halftone keeps its own fill, which is what +/// the effect samples, so an unpainted member still gives an outline. +#[test] +fn real_time_drawing_off_outlines_a_dragged_halftone_group_with_its_own_fill() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [a], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a], "none": true})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"color": "#000000", "ids": []})).unwrap(); + s.execute("select.set", &json!({"ids": [group]})).unwrap(); + s.execute("appearance.addFill", &json!({})).unwrap(); + let fill = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group.as_u64().unwrap())).unwrap().appearance.fill_paint().label(); + assert_eq!(fill, "#000000", "the group's own fill"); + s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "item": null})).unwrap(); + // The Right handle: the unpainted member gives nothing to press on. + assert!(!dragged_feedback_boxes(&mut s, 200.0, 150.0).is_empty(), "the halftone's dots are outlined"); +} + +/// With Real-time Drawing off, a dragged object with Crop Marks draws the object's outline without its +/// marks. +#[test] +fn real_time_drawing_off_outlines_a_dragged_object_without_its_crop_marks() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "cropMarks", "item": null})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 120.0, 120.0), [(130.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, the objects a dragged repeat evaluates to count toward the limit with +/// the points of their outlines: 18,000 rectangles have 90,000 points, and with the objects they come +/// to 108,000. +#[test] +fn real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.repeat.grid", &json!({"rows": 120, "cols": 150})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 105.0, 105.0); + assert_eq!(boxes.len(), 1, "one bounds rectangle: {} boxes", boxes.len()); +} + +/// With Real-time Drawing off, a dragged intertwine draws the outline of each member, without the +/// area one of them is arranged over the other in. +#[test] +fn real_time_drawing_off_outlines_a_dragged_intertwine_member_by_member() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.intertwine.make", &json!({})).unwrap(); + s.execute("object.intertwine.arrange", &json!({"at": [175, 125], "order": "back"})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(130.0, 140.0, 100.0), (180.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, a dragged group whose members have more than 100,000 points between +/// them shows its moved bounds, as a single path of that size does. +#[test] +fn real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let path = |s: &mut Session, y: f64| { + let anchors: Vec = + (0..60_000).map(|i| json!({"x": 100.0 + f64::from(i) * 0.003, "y": if i % 2 == 0 { y } else { y + 50.0 }})).collect(); + s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].clone() + }; + let (a, b) = (path(&mut s, 100.0), path(&mut s, 200.0)); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let at_rest = s.doc().unwrap().doc.bounds_of(&s.doc().unwrap().selection.objects, true).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 200.0, 100.0); + let [(x, y, width)] = boxes.as_slice() else { panic!("one bounds rectangle, got {boxes:?}") }; + assert!((x - at_rest.x0 - 30.0).abs() < 1e-2 && (y - at_rest.y0 - 40.0).abs() < 1e-2 && (width - at_rest.width()).abs() < 1e-2, "{boxes:?}"); +} + /// A deferred commit that fails its preview cancels the interaction instead of leaving it open. #[test] fn deferred_commit_preview_failure_cancels_interaction() { diff --git a/crates/engine/src/tests/scripts.rs b/crates/engine/src/tests/scripts.rs index 5f620b251..8d2add2ea 100644 --- a/crates/engine/src/tests/scripts.rs +++ b/crates/engine/src/tests/scripts.rs @@ -140,7 +140,7 @@ fn pipes_and_folders_are_not_read() { let direct = s.execute("script.check", &json!({ "path": pipe })).map_err(|e| e.to_string()); let _ = tx.send((included.map_err(|e| e.to_string()), direct)); }); - let (included, direct) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("the checks return"); + let (included, direct) = rx.recv_timeout(std::time::Duration::from_secs(300)).expect("the checks return"); let statuses: Vec = included.unwrap().as_array().unwrap().iter().map(|i| i["status"].as_str().unwrap().to_string()).collect(); assert_eq!(statuses, ["missing", "missing"]); assert!(direct.is_err_and(|e| e.contains("not a file"))); diff --git a/crates/engine/src/tests/swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs index b0d0edf86..2a74e194d 100644 --- a/crates/engine/src/tests/swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -3,7 +3,7 @@ //! document's colour mode. use serde_json::{Value, json}; -use vectorcraft_color::Paint; +use vectorcraft_color::{Color, Paint}; use crate::*; @@ -125,6 +125,275 @@ fn add_used_colors_skips_colours_that_have_swatches() { assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global); } +/// Give `id` the fill `p` exactly as written (a command would round or convert some colors). +fn set_fill(s: &mut Session, id: NodeId, p: Paint) { + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.set_fill(p); +} + +/// Rectangles filled with repeated colors, near-equal colors that get the same name, a color and +/// its twin with a -0.0 component, a color whose name a swatch already has, and colors two +/// swatches already have (one of them through -0.0). Returns the rectangles in paint order. +fn used_colors_doc(s: &mut Session) -> Vec { + run(s, "swatch.new", json!({"name": "Gray K=33", "color": "#00ff00"})); + run(s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"})); + let red = doc(s).swatch("Red").unwrap().paint.color().unwrap(); + let colors = [ + Color::rgb(0.2, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6001), + Color::rgb(0.0, 0.4, 0.6), + Color::rgb(-0.0, 0.4, 0.6), + Color::gray(0.33), + Color::gray(0.3301), + red, + Color::rgb(-0.0, 128.0 / 255.0, 1.0), + Color::rgb(0.2, 0.4, 0.6001), + ]; + colors + .into_iter() + .map(|c| { + let id = rect_filled(s, json!({"color": "#000000"})); + set_fill(s, id, Paint::solid(c)); + id + }) + .collect() +} + +const USED_NAMES: [&str; 5] = ["R=51 G=102 B=153", "R=51 G=102 B=153 2", "R=0 G=102 B=153", "Gray K=33 2", "Gray K=33 3"]; + +#[test] +fn add_used_colors_adds_each_new_color_once_with_free_names() { + let mut s = session(); + used_colors_doc(&mut s); + let before = doc(&s).clone(); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.addUsedColors", json!({})); + assert_eq!(r, json!({"added": USED_NAMES, "linked": 0})); + assert_eq!(names(&s, None)[names(&s, None).len() - 5..], USED_NAMES); + let color = |n: &str| doc(&s).swatch(n).unwrap().paint.color().unwrap(); + assert_eq!(color("R=51 G=102 B=153 2"), Color::rgb(0.2, 0.4, 0.6001)); + assert_eq!(color("Gray K=33 3"), Color::gray(0.3301)); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); +} + +#[test] +fn add_used_colors_global_links_every_paint_of_a_new_color() { + let mut s = session(); + let ids = used_colors_doc(&mut s); + let r = run(&mut s, "swatch.addUsedColors", json!({"global": true})); + assert_eq!(r, json!({"added": USED_NAMES, "linked": 8})); + let link = |s: &Session, id: NodeId| match fill(s, id) { + Paint::Solid { swatch, .. } => swatch, + p => panic!("{p:?}"), + }; + // The index in USED_NAMES of the swatch each rectangle links to. + let want = [Some(0), Some(0), Some(1), Some(2), Some(2), Some(3), Some(4), None, None, Some(1)]; + let got: Vec> = ids.iter().map(|id| link(&s, *id)).collect(); + assert_eq!(got, want.map(|i: Option| i.map(|i| USED_NAMES[i].to_string()))); + assert!(USED_NAMES.iter().all(|n| doc(&s).swatch(n).unwrap().global)); +} + +/// A paint linked to Registration keeps its link when a new global swatch has the same color. +#[test] +fn add_used_colors_global_leaves_registration_links_alone() { + let mut s = session(); + let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + let registration = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, registration, reg_paint.clone()); + let plain = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, plain, Paint::solid(Color::cmyk(1.0, 1.0, 1.0, 1.0))); + let r = run(&mut s, "swatch.addUsedColors", json!({"global": true})); + assert_eq!(r["linked"], json!(1), "{r}"); + let name = r["added"][0].as_str().unwrap().to_string(); + assert_eq!(fill(&s, registration), reg_paint); + assert_eq!(fill(&s, plain), Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(name), tint: 1.0 }); +} + +#[test] +fn unused_matches_links_tints_colors_gradients_and_patterns_exactly() { + let mut s = session(); + run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true})); + run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 40})); + run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 70})); + run(&mut s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"})); + let teal = doc(&s).swatch("Teal").unwrap().paint.color().unwrap(); + let tinted = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, tinted, Paint::Solid { color: teal.tinted(0.4), swatch: Some("Teal".into()), tint: 0.4 }); + let zero = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, zero, Paint::solid(Color::rgb(-0.0, 128.0 / 255.0, 1.0))); + // Sunset's gradient without a link to it, beside gradients with Sunset's stop colors and a + // different midpoint or opacity; Ocean's with one stop moved, and Ocean's with another kind, + // method or dither. + let gradient = |s: &Session, n: &str| match &doc(s).swatch(n).unwrap().paint { + Paint::Gradient(g) => g.gradient.clone(), + p => panic!("{p:?}"), + }; + let sunset = gradient(&s, "Sunset"); + let (mut midpoint, mut opacity) = (sunset.clone(), sunset.clone()); + midpoint.stops[0].midpoint = 0.3; + opacity.stops[1].opacity = 0.5; + let ocean = gradient(&s, "Ocean"); + let (mut moved, mut radial, mut perceptual, mut dither) = (ocean.clone(), ocean.clone(), ocean.clone(), ocean); + moved.stops[0].offset += 0.25; + radial.kind = vectorcraft_color::GradientKind::Radial; + perceptual.method = vectorcraft_color::GradientMethod::Perceptual; + dither.dither = true; + for g in [midpoint, sunset.clone(), opacity, sunset, moved, radial, perceptual, dither] { + let id = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, id, Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g)))); + } + let r = rect_filled(&mut s, json!({"color": "#ff0000"})); + run(&mut s, "select.set", json!({"ids": [r.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + rect_filled(&mut s, json!({"swatch": "Dots"})); + let names = run(&mut s, "swatch.unused", json!({}))["names"].clone(); + let names: Vec<&str> = names.as_array().unwrap().iter().filter_map(Value::as_str).collect(); + for used in ["Teal", "Teal 40%", "Zeroed", "Sunset", "Dots"] { + assert!(!names.contains(&used), "{used} is used: {names:?}"); + } + for unused in ["Teal 70%", "Ocean", "Orange", "Red"] { + assert!(names.contains(&unused), "{unused} is unused: {names:?}"); + } +} + +#[test] +fn new_swatches_from_colors_are_numbered_after_names_already_taken() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"})); + run(&mut s, "swatch.newGroup", json!({"name": "Mine"})); + let before = doc(&s).clone(); + let undo = undo_len(&s); + // Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component. + let colors = json!([ + "#ff0000", + "#ff0000", + "#ff0000", + "#00ff00", + "#00ff00", + [0.2, 0.4, 0.6], + [0.2, 0.4, 0.6001], + {"l": 50, "a": 0.0, "b": 0}, + {"l": 50, "a": -0.0, "b": 0} + ]); + let r = run(&mut s, "swatch.new", json!({ "colors": colors })); + let want = [ + "R=255 G=0 B=0", + "R=255 G=0 B=0 3", + "R=255 G=0 B=0 4", + "R=0 G=255 B=0", + "R=0 G=255 B=0 2", + "R=51 G=102 B=153", + "R=51 G=102 B=153 2", + "L=50 a=0 b=0", + "L=50 a=0 b=0 2", + ]; + assert_eq!(r, json!({"name": want[0], "names": want})); + assert_eq!(names(&s, None)[names(&s, None).len() - want.len()..], want); + assert!(matches!(doc(&s).swatch("L=50 a=0 b=0 2").unwrap().paint.color(), Some(Color::Lab { a, .. }) if a.is_sign_negative())); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); + let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000", "#ff0000"]})); + assert_eq!(r["names"], json!(["R=255 G=0 B=0", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4"])); + let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000"], "group": "Mine"})); + assert_eq!(r["names"], json!(["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"])); + assert_eq!(names(&s, Some("Mine")), ["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"]); +} + +#[test] +fn new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"})); + let r = run( + &mut s, + "swatch.newGroup", + json!({"name": "R=255 G=0 B=0", "swatches": ["R=255 G=0 B=0 2"], "colors": ["#ff0000", "#ff0000", "#00ff00", "#ff0000"]}), + ); + let want = ["R=255 G=0 B=0 2", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4", "R=0 G=255 B=0", "R=255 G=0 B=0 5"]; + assert_eq!(r, json!({"name": "R=255 G=0 B=0", "swatches": want, "linked": 0})); +} + +/// New Color Group from artwork lists each color once in paint order: a repeat and a twin with a +/// -0.0 component are the same color, near-equal colors with the same name are numbered after the +/// names already taken, and a paint linked to Registration brings no color. One undo step. +#[test] +fn new_color_group_from_artwork_lists_each_color_once_with_free_names() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "R=0 G=102 B=153", "color": "#00ff00"})); + let colors = [ + Color::rgb(0.2, 0.4, 0.6), + Color::rgb(0.0, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6001), + Color::rgb(-0.0, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6), + Color::lab(50.0, 0.0, 0.0), + Color::lab(50.0, -0.0, 0.0), + Color::lab(50.0, 0.0001, 0.0), + ]; + let mut ids: Vec = vec![]; + for c in colors { + let id = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, id, Paint::solid(c)); + ids.push(id.0); + } + let registration = rect_filled(&mut s, json!({"color": "#000000"})); + let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + set_fill(&mut s, registration, reg_paint); + ids.push(registration.0); + run(&mut s, "select.set", json!({ "ids": ids })); + let before = doc(&s).clone(); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.newGroup", json!({"name": "R=51 G=102 B=153 2", "fromArtwork": true})); + let want = ["R=51 G=102 B=153", "R=0 G=102 B=153 2", "R=51 G=102 B=153 3", "L=50 a=0 b=0", "L=50 a=0 b=0 2"]; + assert_eq!(r, json!({"name": "R=51 G=102 B=153 2", "swatches": want, "linked": 8})); + assert_eq!(names(&s, Some("R=51 G=102 B=153 2")), want); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); +} + +#[test] +fn edit_color_group_numbers_extra_colors_after_the_names_it_keeps() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Mine", "colors": ["#ff0000", "#00ff00", "#0000ff"]})); + let before = doc(&s).clone(); + let undo = undo_len(&s); + // Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component. + let colors = json!([ + "#0000ff", + "#ff0000", + "#ff0000", + "#ff0000", + "#ff0000", + "#00ff00", + [0.2, 0.4, 0.6], + [0.2, 0.4, 0.6001], + {"l": 50, "a": 0.0, "b": 0}, + {"l": 50, "a": -0.0, "b": 0} + ]); + let r = run(&mut s, "swatch.editGroup", json!({"group": "Mine", "colors": colors, "rename": "R=255 G=0 B=0 2"})); + let want = [ + "R=255 G=0 B=0", + "R=0 G=255 B=0", + "R=0 G=0 B=255", + "R=255 G=0 B=0 3", + "R=255 G=0 B=0 4", + "R=0 G=255 B=0 2", + "R=51 G=102 B=153", + "R=51 G=102 B=153 2", + "L=50 a=0 b=0", + "L=50 a=0 b=0 2", + ]; + assert_eq!(r, json!({"name": "R=255 G=0 B=0 2", "swatches": want, "relinked": 0})); + assert_eq!(names(&s, Some("R=255 G=0 B=0 2")), want); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); +} + #[test] fn unused_leaves_out_linked_colours_gradients_and_patterns_in_use() { let mut s = session(); @@ -234,7 +503,7 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { // A swatch made from a hex colour is CMYK as well; the document round-trips natively. let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string(); assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0)))); - assert_eq!(name, "C=62 M=0 Y=100 K=0"); + assert_eq!(name, "C=61 M=0 Y=100 K=0"); let json = serde_json::to_string(doc(&s)).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), *doc(&s)); } diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index 5dfd7ca09..4de6abd39 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -407,7 +407,7 @@ fn an_alt_drag_in_perspective_on_a_grid_from_an_older_file_attaches_the_copies() /// first one kept, and the drag leaves what one `perspective.move` with copy leaves, as one undo /// step. #[test] -fn alt_drag_in_perspective_of_30000_objects_finishes_within_20_s() { +fn alt_drag_in_perspective_of_30000_objects_takes_few_tree_walks() { // The squares are 6 pt wide and 10 pt apart, in rows of 150, between the left vanishing point // of the two-point grid and its origin. let on_the_left = |d: &mut Document, k: usize| { @@ -904,6 +904,31 @@ fn coordinate_moves_journal_the_locked_art_preference() { s.prefs.move_locked_with_artboard = false; s.execute(&id, &p).unwrap(); assert_eq!(bounds(&s, locked), Rect::new(130.0, 100.0, 330.0, 200.0)); + s.execute("edit.undo", &json!({})).unwrap(); + // A null reads as a key left out, and journals the value it resolved to. + s.prefs.move_locked_with_artboard = true; + s.execute("artboard.setProps", &json!({"index": 0, "x": 30, "moveArt": true, "lockedAndHidden": null})).unwrap(); + assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], true); +} + +/// A drag with the Artboard tool journals whether locked art went along, so a replay under the +/// other preference moves the same art. +#[test] +fn artboard_tool_drag_journals_the_locked_art_preference() { + let mut s = session(); + let locked = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.prefs.move_locked_with_artboard = true; + gesture(&mut s, "artboard", &[(400.0, 300.0), (420.0, 310.0), (450.0, 330.0), (450.0, 330.0)], Mods::default()); + let moved = Rect::new(150.0, 130.0, 200.0, 180.0); + assert!(close(bounds(&s, locked), moved)); + let (id, p) = s.journal.last().unwrap().clone(); + assert_eq!((id.as_str(), &p["lockedAndHidden"]), ("artboard.move", &json!(true))); + s.execute("edit.undo", &json!({})).unwrap(); + s.prefs.move_locked_with_artboard = false; + s.execute(&id, &p).unwrap(); + assert!(close(bounds(&s, locked), moved)); } /// Locked and hidden art scales only with Move Locked and Hidden Artwork with Artboard, as it diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 2d85d620f..1871722c3 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -339,6 +339,7 @@ impl Session { transform_tools_guides: self.prefs.transform_tools_guides, spacing_guides: self.prefs.spacing_guides, distance_guides: self.prefs.distance_guides, + snap_to_last_location: self.prefs.snap_to_last_location, snapping_tolerance: self.prefs.snapping_tolerance, construction_angles: if self.prefs.construction_guides { vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles) @@ -1041,23 +1042,13 @@ impl Session { /// `crates/ui-egui/src/canvas/outline.rs`). Past it the selection's bounds are drawn instead. const FEEDBACK_MAX_POINTS: usize = 100_000; -/// The outline of every selected object, or the selection's bounds when the outlines have more than -/// [`FEEDBACK_MAX_POINTS`] points. +/// The outlines of every selected object, a group's members each with their own, or the selection's +/// bounds when the outlines' points and the objects evaluated to make them come to more than +/// [`FEEDBACK_MAX_POINTS`]. fn outlines_for_selection(doc: &vectorcraft_doc::Document, selection: &Selection) -> Vec { - let mut left = FEEDBACK_MAX_POINTS; - let mut outlines = vec![]; - for &id in &selection.objects { - let Some((path, _)) = doc.node(id).and_then(|n| vectorcraft_render::drawn_outline(doc, n)) else { continue }; - let points = path.elements().iter().filter(|el| !matches!(el, kurbo::PathEl::ClosePath)).count(); - let Some(l) = left.checked_sub(points) else { - return doc - .bounds_of(&selection.objects, true) - .map(|b| Overlay::Path { path: vectorcraft_geom::shapes::rectangle(b).to_bezpath(), color: FEEDBACK, width: 1.0, dashed: false }) - .into_iter() - .collect(); - }; - left = l; - outlines.push(Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: false }); - } - outlines + let nodes = selection.objects.iter().filter_map(|&id| doc.node(id)); + let outlines = vectorcraft_render::drawn_outlines(doc, nodes, FEEDBACK_MAX_POINTS).unwrap_or_else(|| { + doc.bounds_of(&selection.objects, true).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath()).into_iter().collect() + }); + outlines.into_iter().map(|path| Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: false }).collect() } diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 42970aa16..5f16715da 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -243,6 +243,10 @@ fn structured_junk() { ("addon.select.byLinkStatus", json!({"status": "embedded", "ids": {"a": 1}, "add": [1], "zoom": "yes"})), ("addon.select.byLinkStatus", json!({"status": ["modified", "embedded"], "ids": [], "add": true, "zoom": true})), ("addon.select.byLinkStatus", json!({"status": [[["linked"]]], "ids": null})), + ("links.relink", json!({"scope": "each", "paths": [{"ids": [2, u64::MAX, -1, "x"], "path": ""}, null, 7, {"path": [1]}]})), + ("links.relink", json!({"scope": ["all"], "path": "/no/such/file.png", "ids": {"a": 1}})), + ("links.relink", json!({"scope": "missingInFolder", "path": "", "folder": [], "paths": "x"})), + ("links.relink", json!({"scope": "all", "path": "/no/such/file.png", "ids": [2, u64::MAX, -1, 1.5]})), ("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})), ("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})), ("object.transform", json!({"matrix": [1, 2, 3]})), @@ -911,7 +915,15 @@ fn every_command_undoes_exactly() { let mut s = Fixture::Multi.session(); let before = doc_json(&s.doc().unwrap().doc); let depth = s.doc().unwrap().history.undo.len(); + let source = s.active_index().unwrap(); let Ok(Ok(_)) = catch_quiet(|| s.execute(c.id, &json!({}))) else { continue }; + // A command that makes a new document and makes it active leaves the source document as it is. + if s.active_index() != Some(source) { + if s.documents().get(source).map(|d| doc_json(&d.doc)).as_ref() != Some(&before) { + failures.push(format!("{}: changed the source document while making another active", c.id)); + } + continue; + } if s.doc().unwrap().history.undo.len() > depth { let _ = no_panic(s.execute("edit.undo", &json!({}))); let after = doc_json(&s.doc().unwrap().doc); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f1a1f62c0..73c497032 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1148,6 +1148,42 @@ fn styles(what: &str, data: &str) -> Result<(), TestCaseError> { }) } +/// A document with type and a character and a paragraph style, saved as pretty native text: what +/// Load All Styles reads. +fn saved_text_styles() -> String { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Styled"})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Lead", "attrs": {"size": 30, "tracking": 50, "underline": true, "leading": 36}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center", "space_after": 6, "hyphenate": true}})).unwrap(); + let options = vectorcraft_format::SaveOptions { version: 2, pretty: true, ..vectorcraft_format::SaveOptions::default() }; + let text = String::from_utf8(vectorcraft_format::save_with(&s.doc().unwrap().doc, &options).unwrap()).unwrap(); + // Undamaged, it loads both styles. + let r = rich_session().execute("textStyle.loadAll", &json!({"data": text, "name": "fuzz.vectorcraft"})).unwrap(); + assert_eq!((&r["charStyles"]["added"], &r["paraStyles"]["added"]), (&json!(["Lead"]), &json!(["Centered"]))); + text + }) + .clone() +} + +/// Native file text `data`'s styles loaded into the rich document by Load All Styles, and each +/// style it added applied to all of its type. +fn text_styles(what: &str, data: &str) -> Result<(), TestCaseError> { + survive(what, || { + let mut s = rich_session(); + let r = no_panic(s.execute("textStyle.loadAll", &json!({"data": data, "name": "fuzz.vectorcraft"}))).ok()?; + let _ = no_panic(s.execute("select.all", &json!({}))); + for (list, kind) in [("charStyles", "charStyle"), ("paraStyles", "paraStyle")] { + for name in r[list]["added"].as_array().into_iter().flatten() { + let _ = no_panic(s.execute(&format!("{kind}.apply"), &json!({"name": name}))); + } + } + Some((*s.doc().ok()?.doc).clone()) + }) +} + /// Libraries panel library file `data` imported, then each kind's first item used on the rich /// document, moved into its first group and next to its kind's last item, a colour and the /// selection added into that group next to the first ones, the group moved and deleted and the @@ -1312,6 +1348,11 @@ proptest! { styles("mutated style library", &mutate_text(&text, cut, &edits))?; } + #[test] + fn mutated_text_style_files_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { + text_styles("mutated text styles", &mutate_text(&saved_text_styles(), cut, &edits))?; + } + #[test] fn mutated_flattener_presets_never_panic(cut in 0usize..5_000, edits in prop::collection::vec(arb_edit(), 0..10)) { let text = saved("flattener.presets.export", json!({"names": ["high", "medium", "low"]})); @@ -3043,6 +3084,36 @@ fn arb_area_options() -> impl Strategy { prop::collection::vec((keys, values), 0..8).prop_map(|kv| kv.iter().map(|(k, v)| format!("/{k} {v}")).collect::>().join(" ")) } +/// Type on a Path Options as a frame might keep them at key 11: nothing, a value that isn't a +/// dictionary, or a dictionary of codes and flags of any value or type. +fn arb_path_options() -> impl Strategy { + let keys = prop::sample::select(vec!["0", "1", "2", "3", "4", "6", "7", "18", "22"]); + let values = prop::sample::select(vec![ + "0", + "1", + "2", + "3", + "4", + "5", + "-1", + "2.5", + "1e400", + "NaN", + "inf", + "(x)", + "true", + "false", + "/Name", + "[ 1 ]", + "<< /0 1 >>", + ]); + (prop::collection::vec((keys, values), 0..6), 0usize..3).prop_map(|(kv, shape)| match shape { + 0 => format!("/11 << {} >>", kv.iter().map(|(k, v)| format!("/{k} {v}")).collect::>().join(" ")), + 1 => "/11 3".to_string(), + _ => String::new(), + }) +} + /// A text document of `kinds.len()` stories, story `i` in frame `i` of kind `kinds[i]` (0 point type, /// 1 area type, 2 type on a path, others a kind the reader doesn't know), with a line break inside /// its paragraph (`\x03`) at each of `soft` (by character, wrapping, the story's end included) and @@ -3192,6 +3263,18 @@ proptest! { survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// The type on a path of the same text document with hostile Type on a Path Options, opened as + /// an EPS document and as a `.ai`'s editing data. + #[test] + fn text_frames_with_hostile_path_options_never_panic(options in arb_path_options()) { + let text = frames_document_text().replacen("/6 [ 0.5 2.0 ]", &format!("/6 [ 0.5 2.0 ] {options}"), 1); + let editing = text_editing_of(&text, FRAME_OBJECTS); + let bytes = editing_eps(&editing); + survive("EPS text frames with hostile path options", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?; + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing)].concat(); + survive(".ai text frames with hostile path options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// The same for vertical type and Tate-chu-yoko runs: orientations and baseline directions of /// any value, type or size. #[test] @@ -3246,6 +3329,40 @@ proptest! { survive("hostile .ai envelope", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// A `.ai` whose warped type is damaged or hostile: any warp style and settings, on the object or + /// on a fill, a look that reaches far away or holds type of its own, the text cut short. + #[test] + fn ai_warped_type_never_panics( + style in prop::sample::select(vec![0i64, 1, 8, 15, 16, -3, i64::MAX]), + bend in prop::sample::select(vec!["0.5", "0", "1", "-1", "2", "1e308", "-1e308", "nan"]), + distort in prop::sample::select(vec!["0", "0.4", "-1", "1e308", "nan"]), + reach in prop::sample::select(vec!["90", "16000", "1e7", "1e308", "-1e308", "nan"]), + on_fill in any::(), + look_type in any::(), + objects in 1usize..4, + cut in 0usize..3_000, + edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '(', ')', '/', ':', ';', ',', '[', ']', '%', '_', 'X', 'u', 'U'])), 0..8), + ) { + let basic = |name: &str, body: &str| format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n"); + let chain = |kids: &[String]| format!("/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 1 1 0 1 0 0 Xa\n0 1 0 0 0 Xy\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"); + let warp = basic("Adobe Deform", &format!("{style} /Int (DeformStyle) ,\n{bend} /Real (DeformValue) ,\n{distort} /Real (DeformHoriz) ,\n{distort} /Real (DeformVert) ,\n0 /Bool (Rotate) ,\n")); + let root = if on_fill { + let stack = format!("/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n{}{};\n /Part ,\n", chain(&[warp, fill]), basic("Conduit Filter", "")); + chain(&[stack]) + } else { + chain(&[fill, warp]) + }; + let styles = format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Warped) /Name ,\n/ActiveStyle :\n{root}%AI9_EndArtStyles\n"); + let slot = if look_type { "/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n" } else { "" }; + let object = format!( + "u\n{slot}0 1 1 0 1 0 0 Xa\n10 10 m\n{reach} 10 L\n{reach} {reach} L\n10 90 L\nf\nU\n9 () XW\n%_/AI11Text :\n%_0 /FreeUndo ,\n%_0 /FrameIndex ,\n%_0 /StoryIndex ,\n%_;\n1 (Warped) XW\n" + ); + let data = mutate_text(&text_editing_of(&vertical_document_text(), &(styles + &object.repeat(objects))), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat(); + survive("hostile .ai warped type", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose editing data is damaged or hostile. #[test] fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'l', 'c', 'C', 'v', 'V', 'y', 'Y', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::()), 0..4)) { @@ -3260,6 +3377,23 @@ proptest! { survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// The active artboard that a restored `.ai` opens on, read from damaged or hostile editing data. + #[test] + fn ai_active_artboard_never_panics(cut in 0usize..600, edits in prop::collection::vec((0usize..600, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '/', ':', ';', ',', '%', '_'])), 0..12)) { + let board = |x: u32| format!("%_/Dictionary :\n%_{x} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", x + 100); + let text = format!( + "%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_1 /Int (CropAreaActive) ,\n%_; /Recorded ,\n%_;\n%AI9_EndDocumentData\n", + board(0), + board(150) + ); + let private = mutate_text(&text, cut, &edits).into_bytes(); + survive("hostile CropAreaActive", || { + let mut doc = Document::new(100.0, 100.0); + doc.opened_artboard = vectorcraft_eps::ai_opened_artboard(&private, &doc); + Some(doc) + })?; + } + /// A `.ai` whose PDF part can't be read (#4366), its private data damaged or hostile, under /// any key and filter: it opens from its editing data, or with the error of its PDF part. #[test] @@ -3331,6 +3465,26 @@ proptest! { survive("hostile .ai line widths", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// A `.ai` whose legacy type is set in a CJK composite font (a predefined CMap the reader + /// carries, one it reads through another, one it decodes byte by byte, a Unicode CMap, or a + /// name that only looks like one), with any bytes in its strings. + #[test] + fn ai_legacy_type_in_cjk_fonts_never_panics( + cmap in prop::sample::select(vec![ + "83pv-RKSJ-H", "90ms-RKSJ-V", "90pv-RKSJ-V", "H", "V", "EUC-H", "GBK2K-H", "CNS-EUC-V", "KSCpc-EUC-H", "KSCms-UHC-HW-V", + "78-RKSJ-H", "GB-H", "KSC-V", "HKdlb-B5-H", "GBT-H", "UniJIS-UCS2-H", "UniGB-UTF8-V", "UniKS-UTF32-H", "UniAKR-UTF16-H", + "Identity-H", "UniFoo-UTF8-H", "-H", "RKSJ", + ]), + strings in prop::collection::vec(prop::collection::vec(any::(), 0..24), 1..4), + ) { + use vectorcraft_testkit::ai; + let tx: String = strings.iter().map(|s| format!("({}) Tx\n", s.iter().map(|b| format!("\\{b:03o}")).collect::())).collect(); + let art = format!("0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n/_Test-W3-{cmap} 9 Tf\n{tx}(\\r) TX\nTO\n"); + let data = ai::editing_data(100.0, 100.0, &ai::layer("L", &art)); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat(); + survive("legacy type in CJK fonts", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose pattern definition (a marked tile box, a gradient placed by `Bg`'s matrix) and /// pattern fill are damaged or hostile. #[test] @@ -3645,8 +3799,9 @@ proptest! { } /// Brush objects of every kind with any parameters, the brush manager's order with any names, - /// and brush art sections nested or cut short: read with the layers, or as a library without - /// them, the brushes kept within their ranges. + /// and brush art sections nested or cut short, their art alone or in a group after a frame of + /// any corners, painted or not, open or closed, the group ended or not: read with the layers, + /// or as a library without them, the brushes kept within their ranges. #[test] fn ai_brushes_never_panic( kind in 0usize..7, @@ -3655,6 +3810,7 @@ proptest! { nest in 0usize..6, cut in any::(), layers in any::(), + frame in prop::option::of((prop::collection::vec(prop_oneof![arb_num(), Just("0".to_string()), Just("4".to_string())], 4), prop::sample::select(vec!["n", "N", "f", "S"]), any::())), ) { use vectorcraft_testkit::ai; let kinds = ["Adobe Calligraphic Brush Tool", "Adobe Scatter Brush Tool", "Adobe ArtOnPath Brush Tool", "Adobe PatternOnPath Brush Tool", "Adobe dBrush Brush Tool", "Adobe Brush Manager Order", "Other"]; @@ -3668,7 +3824,14 @@ proptest! { for _ in 0..nest { art.push_str("%AI8_BeginBrushPattern\n(A)\n"); } - art.push_str("0 g\n0 0 m\n4 0 L\n4 4 L\nf\n"); + match &frame { + Some((c, op, ended)) => { + let (x0, y0, x1, y1) = (&c[0], &c[1], &c[2], &c[3]); + let end = if *ended { "U\n" } else { "" }; + art.push_str(&format!("u\n{x0} {y0} m\n{x1} {y0} L\n{x1} {y1} L\n{x0} {y1} L\n{x0} {y0} L\n{op}\n0 g\n0 0 m\n4 0 L\n4 4 L\nf\n{end}")); + } + None => art.push_str("0 g\n0 0 m\n4 0 L\n4 4 L\nf\n"), + } for _ in 0..nest.saturating_sub(usize::from(cut)) { art.push_str("%AI8_EndBrushPattern\n"); } @@ -3691,6 +3854,91 @@ proptest! { survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; } + + /// A `.ai` whose art styles, as the writer writes them, are damaged: the styles, the style + /// list, a styled object's drawn look, the object on `%_` lines and its `1 (style) XW`, cut + /// short or edited. + #[test] + fn ai_written_styles_never_panic(cut in 0usize..STYLES_FUZZ_CHARS, edits in prop::collection::vec((0usize..STYLES_FUZZ_CHARS, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'X', 'W'])), 0..12)) { + let text = mutate_text(styles_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("mutated .ai art styles", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + survive("mutated .ai art styles, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } +} + +/// The range of [`ai_written_styles_never_panic`]'s cuts and edits: past the end of [`styles_text`]. +const STYLES_FUZZ_CHARS: usize = 64_000; + +/// The editing data VectorCraft writes for a document with a drop shadow on a path, a gradient fill +/// and a width profile on others, a stroked even-odd path with an opacity mask and Pathfinder › Add +/// on a group. +fn styles_text() -> &'static str { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile}; + use vectorcraft_geom::{FillRule, Rect, shapes}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let fx = |id: &str| vectorcraft_render::effects::new_effect(id, &json!({})).unwrap(); + let mut ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 2.0); + ap.effects = vec![fx("stylize.dropShadow")]; + let shaded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap.clone()); + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let ramp = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]); + let mut graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0); + graded.effects = vec![fx("stylize.outerGlow")]; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), graded); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let mut tapered = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + tapered.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.5), (1.0, 0.0, 1.0)] }); + let profiled = Appearance { items: vec![AppearanceItem::Stroke(tapered)], effects: vec![fx("stylize.roundCorners")], contents_index: None }; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 150.0, 40.0)), profiled); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let mut stroked = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0); + stroked.effects = vec![fx("stylize.dropShadow")]; + let mut odd = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 50.0, 150.0, 90.0)), stroked); + if let NodeKind::Path { rule, .. } = &mut odd.kind { + *rule = FillRule::EvenOdd; + } + let mask_art = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(100.0, 50.0, 150.0, 70.0)), + Appearance::basic(Paint::solid(Color::gray(0.0)), Paint::None, 0.0), + ); + odd.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, false))); + doc.insert(Some(layer), usize::MAX, odd).unwrap(); + let square = |doc: &mut Document, x: f64| { + Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(x, 50.0, x + 30.0, 80.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ) + }; + let (a, b) = (square(&mut doc, 60.0), square(&mut doc, 80.0)); + let mut merged = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]); + merged.appearance = Appearance { effects: vec![fx("pathfinder.add")], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, merged).unwrap(); + let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); + String::from_utf8(out.program).unwrap() + }) +} + +/// The data [`ai_written_styles_never_panic`] damages, as it is, opens with its styles live, and the +/// property's cuts and edits reach all of it. +#[test] +fn the_written_styles_fuzz_data_opens_live() { + let text = styles_text(); + assert!(text.chars().count() < STYLES_FUZZ_CHARS, "{}", text.chars().count()); + for mark in ["%AI9_BeginArtStyles", "%AI5_BeginLayer", "\n6 () XW", "\n1 (Anon", "(BSLeftWidthsArray)", "0 /Bool (UseEvenOdd)", "Bb"] { + assert!(text.contains(mark), "{mark}"); + } + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat(); + let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + assert!(layered, "{warnings:?}"); + let art = d.layers[0].children().unwrap(); + assert_eq!(art.iter().map(|n| n.appearance.effects.len()).collect::>(), [1, 1, 1, 1, 1]); } /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the @@ -4323,6 +4571,8 @@ fn arb_ai_token() -> impl Strategy { "1e308 Ty", "1 TY", "(x) TY", + "3 Tg", + "1e308 Tg", "1 1 1e308 -1e308 4e9 Th", "1 1 (2) 3 4 Th", "36 2 Xb", diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index 65b2f423c..16deb3d24 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -280,6 +280,348 @@ fn a_moved_linked_dimension_stays_where_it_was_dropped() { assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "moved together"); } +/// How many nodes a dimension's art holds, how many of them are type, and whether it has a recipe. +fn art(s: &Session, id: u64) -> (usize, usize, bool) { + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap(); + let (mut nodes, mut labels) = (0, 0); + n.walk(&mut |c| { + nodes += 1; + labels += usize::from(matches!(c.kind, NodeKind::Text(_))); + }); + (nodes, labels, n.extras().dimension.is_some()) +} +fn bounds(s: &Session, id: u64) -> [f64; 4] { + let b = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap(); + [b.x0, b.y0, b.x1, b.y1] +} +/// The art of `id`, which holds no type and no recipe, follows a move, a scale, undo and redo as +/// plain art does. +fn stays_plain_art(s: &mut Session, id: u64) { + let plain = art(s, id); + assert_eq!(plain.1, 0, "no type left: {plain:?}"); + let at = bounds(s, id); + run(s, "select.set", json!({"ids":[id]})); + run(s, "object.move", json!({"dx":30,"dy":-20})); + assert_eq!(art(s, id), plain, "after a move"); + let moved = bounds(s, id); + for (k, (a, b)) in at.iter().zip(moved).enumerate() { + let d = if k % 2 == 0 { 30.0 } else { -20.0 }; + assert!((b - a - d).abs() < 1e-6, "{at:?} moved to {moved:?}"); + } + run(s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(art(s, id), plain, "after a scale"); + let scaled = bounds(s, id); + assert!(((scaled[2] - scaled[0]) - 2.0 * (at[2] - at[0])).abs() < 1e-6, "{at:?} scaled to {scaled:?}"); + run(s, "edit.undo", json!({})); + assert_eq!(art(s, id), plain, "after undoing the scale"); + run(s, "edit.redo", json!({})); + assert_eq!(art(s, id), plain, "after redoing the scale"); + run(s, "edit.undo", json!({})); + run(s, "edit.undo", json!({})); + assert_eq!(art(s, id), plain); + assert_eq!(bounds(s, id), at); + assert!(!plain.2, "no recipe left"); +} + +/// Object › Expand turns a dimension into plain art for good: it no longer follows the art it +/// measured, and one undo gives the live dimension back. +#[test] +fn an_expanded_dimension_stays_plain_art() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let other = dim(&mut s, line); + let live = art(&s, id); + assert_eq!((live.1, live.2), (1, true)); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.expand", json!({})); + stays_plain_art(&mut s, id); + let plain = art(&s, id); + let at = bounds(&s, id); + assert_eq!(art(&s, other), live, "the dimension left out of the selection stays live"); + // The art it measured no longer moves it, and deleting that art leaves it. + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(text(&s, other), "20.00 in"); + assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); + run(&mut s, "edit.clear", json!({})); + assert_eq!((art(&s, id), bounds(&s, id)), (plain, at)); + assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none()); + // Saved and opened again, it is still plain art. + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let mut reopened = Session::new(); + reopened.add_document(doc, None); + stays_plain_art(&mut reopened, id); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "one undo gives the live dimension back"); + assert_eq!(text(&s, id), "10.00 in"); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(text(&s, id), "20.00 in", "linked again"); +} + +/// Expand's options: any one that changes a dimension's art ends its link, and an Expand that +/// changes nothing leaves it live. +#[test] +fn expand_options_that_change_a_dimension_end_its_link() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + assert!(s.execute("object.expand", &json!({"object":false,"stroke":false})).is_err(), "no fills to expand"); + assert_eq!(art(&s, id), live); + assert!(s.doc().unwrap().doc.dimensions); + // Stroke alone leaves type as it is, so a stroked label on its own does not end the link. + let label = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id.0 + }; + run(&mut s, "paint.setStroke", json!({"ids":[label],"color":"#ff0000"})); + run(&mut s, "select.set", json!({"ids":[label]})); + assert!(s.execute("object.expand", &json!({"object":false,"fill":false})).is_err(), "no paths to outline"); + assert!(art(&s, id).2, "still live"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live); + run(&mut s, "select.set", json!({"ids":[id]})); + // Stroke alone: the label stays type, in art that no longer redraws. + run(&mut s, "object.expand", json!({"object":false,"fill":false})); + let plain = art(&s, id); + assert_eq!(plain.1, 1, "{plain:?}"); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain); + assert!(!plain.2, "no recipe left"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live); + // Object alone: the lines stay stroked paths, in art that no longer redraws. + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.expand", json!({"stroke":false,"fill":false})); + let plain = art(&s, id); + assert_eq!(plain.1, 0, "{plain:?}"); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain); + assert!(!plain.2, "no recipe left"); +} + +/// An Expand that ends up changing no art fails and leaves the dimension live: Outline Stroke skips +/// guides, here the dimension's lines. +#[test] +fn an_expand_that_changes_no_art_leaves_a_dimension_live() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let lines: Vec = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect() + }; + run(&mut s, "select.set", json!({"ids":lines})); + run(&mut s, "view.guides.make", json!({})); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + assert!(s.execute("object.expand", &json!({"object":false,"fill":false})).is_err()); + assert_eq!(art(&s, id), live); + assert!(live.2); + // A stroked path selected with it expands, and the dimension's art stays live. + let other = run(&mut s, "shape.rectangle", json!({"x":300,"y":300,"width":50,"height":50}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[other],"color":"#000000"})); + let moved_with = s.doc().unwrap().doc.node_count(); + run(&mut s, "select.set", json!({"ids":[id, other]})); + run(&mut s, "object.expand", json!({"object":false,"fill":false})); + assert_ne!(s.doc().unwrap().doc.node_count(), moved_with, "the rectangle's stroke is outlined"); + assert_eq!(art(&s, id), live); +} + +/// A dimension selected with the art it measures, or held by a selected group, expands the same +/// way: outlining that art does not remove it. +#[test] +fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + run(&mut s, "select.set", json!({"ids":[line, id]})); + run(&mut s, "object.expand", json!({})); + stays_plain_art(&mut s, id); + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.group", json!({})); + run(&mut s, "object.expand", json!({})); + stays_plain_art(&mut s, id); +} + +/// Expand replacing only the art a selected dimension measures leaves the dimension's art as it +/// is, without a link to the art that went. +#[test] +fn a_dimension_keeps_no_link_to_the_art_expand_replaced() { + for circle in [false, true] { + let mut s = session(); + let shape = json!({"x":100,"y":100,"width":72,"height":72}); + let art_id = run(&mut s, if circle { "shape.ellipse" } else { "shape.rectangle" }, shape)["id"].as_u64().unwrap(); + run(&mut s, "paint.setFill", json!({"ids":[art_id],"gradient":{"kind":"linear"}})); + let id = if circle { + run(&mut s, "dimension.create", json!({"mode":"radius","source":{"kind":"circle","node":art_id}}))["id"].as_u64().unwrap() + } else { + dim(&mut s, art_id) + }; + let (nodes, labels, _) = art(&s, id); + run(&mut s, "select.set", json!({"ids":[art_id, id]})); + run(&mut s, "object.expand", json!({"object":false,"stroke":false})); + assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}"); + } +} + +/// Outline Stroke and Expand Appearance replacing the art a selected dimension measures leave the +/// dimension as a plain group, whether or not they change its own art. +#[test] +fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() { + for command in ["object.path.outlineStroke", "effect.expandAppearance"] { + for whole in [true, false] { + let mut s = session(); + let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap(); + run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"})); + run(&mut s, "select.set", json!({"ids":[art_id]})); + run(&mut s, "appearance.addStroke", json!({})); + let id = dim(&mut s, art_id); + let lines: Vec = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect() + }; + run(&mut s, "select.set", json!({"ids":lines})); + run(&mut s, "view.guides.make", json!({})); + let picked = if whole { id } else { lines[0] }; + run(&mut s, "select.set", json!({"ids":[art_id, picked]})); + run(&mut s, command, json!({})); + assert!(s.doc().unwrap().doc.node(NodeId(id)).is_some(), "{command} removed the dimension, whole: {whole}"); + let plain = art(&s, id); + assert!(!plain.2, "{command}: no recipe left, whole: {whole}"); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain, "{command}, whole: {whole}"); + } + } +} + +/// A part of a dimension expanded on its own ends the link too, so the move does not undo it. +#[test] +fn expanding_a_part_of_a_dimension_ends_its_link() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let part = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap()[0].id; + run(&mut s, "select.set", json!({"ids":[part]})); + run(&mut s, "object.expand", json!({})); + let plain = art(&s, id); + assert_eq!(plain.1, 1, "{plain:?}"); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain); + assert!(!plain.2, "no recipe left"); +} + +/// The instance of a symbol whose art is a dimension expands to art that no longer redraws. +#[test] +fn a_dimension_in_an_expanded_symbol_instance_stays_plain_art() { + let mut s = session(); + let id = run(&mut s, "dimension.create", json!({"a":[100,100],"b":[172,100],"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[id]})); + let instance = run(&mut s, "symbol.new", json!({"name":"Measure"}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids":[instance]})); + run(&mut s, "object.expand", json!({})); + let root = s.doc().unwrap().selection.objects[0].0; + let mut recipes = 0; + s.doc().unwrap().doc.node(NodeId(root)).unwrap().walk(&mut |c| recipes += usize::from(c.extras().dimension.is_some())); + assert_eq!(recipes, 0); + stays_plain_art(&mut s, root); +} + +/// Create Outlines on a dimension, or on its label alone, ends the link in the same undo step. +#[test] +fn outlining_a_dimension_or_its_label_ends_its_link() { + for label_alone in [false, true] { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let live = art(&s, id); + let label = { + let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap(); + children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id + }; + let target = if label_alone { label.0 } else { id }; + run(&mut s, "select.set", json!({"ids":[target]})); + run(&mut s, "type.createOutlines", json!({})); + stays_plain_art(&mut s, id); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "one undo gives the live dimension back"); + assert_eq!(text(&s, id), "10.00 in"); + } +} + +/// Outline Stroke on a dimension ends its link: the label stays type and the art does not redraw. +#[test] +fn outlining_a_dimensions_strokes_ends_its_link() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.path.outlineStroke", json!({})); + let plain = art(&s, id); + assert_eq!(plain.1, 1, "{plain:?}"); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + assert_eq!(art(&s, id), plain); + assert!(!plain.2, "no recipe left"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "one undo gives the live dimension back"); +} + +/// Expand Appearance, which the menus offer for a dimension in place of Expand, ends its link too. +#[test] +fn expanding_a_dimensions_appearance_ends_its_link() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let live = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], true); + run(&mut s, "effect.expandAppearance", json!({})); + stays_plain_art(&mut s, id); + run(&mut s, "edit.undo", json!({})); + assert_eq!(art(&s, id), live, "one undo gives the live dimension back"); + assert_eq!(text(&s, id), "10.00 in"); +} + +/// Ungroup takes the group that holds the recipe away, so the parts stay ordinary art. +#[test] +fn an_ungrouped_dimension_stays_ordinary_art() { + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + let (nodes, ..) = art(&s, id); + run(&mut s, "select.set", json!({"ids":[id]})); + run(&mut s, "object.ungroup", json!({})); + assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none()); + let parts = s.doc().unwrap().selection.objects.clone(); + assert_eq!(parts.len(), nodes - 1); + let boxes = |s: &Session| parts.iter().map(|p| s.doc().unwrap().doc.node(*p).unwrap().geometric_bounds().unwrap()).collect::>(); + let at = boxes(&s); + run(&mut s, "object.move", json!({"dx":30,"dy":0})); + for (a, b) in at.iter().zip(boxes(&s)) { + assert!((b.x0 - a.x0 - 30.0).abs() < 1e-6 && (b.y0 - a.y0).abs() < 1e-6, "{a:?} moved to {b:?}"); + } + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert!(!s.doc().unwrap().doc.dimensions); +} + #[test] fn dimension_options_are_persistent_and_invalid_updates_report_errors() { let mut s = session(); diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs new file mode 100644 index 000000000..6c84c66b3 --- /dev/null +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -0,0 +1,102 @@ +//! The JavaScript parser's lexer (`vectorcraft-js`) and the script checker's (`vectorcraft-scriptcheck`) +//! read the same scripts the same way: both crates stand alone, so each has its own lexer, and these +//! tests run both over one set of scripts and fail on any disagreement about the lines a script has, +//! its preprocessor lines, and where a string, comment or regular expression that is never closed +//! starts. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which +//! only the checker reads). + +use vectorcraft_js::{Options, TokenKind, parse, tokenize}; +use vectorcraft_scriptcheck::{Limits, check}; + +/// The problems the checker reports from its lexer, which the parser reports as a `SyntaxError`. +const LEXICAL: [(&str, &str); 3] = [ + ("unterminatedString", "unterminated string"), + ("unterminatedComment", "unterminated comment"), + ("unterminatedRegExp", "unterminated regular expression"), +]; + +/// Scripts both lexers read without a problem, each with a `/` or a quote in a place where reading +/// it the other way would change the tokens. +const VALID: &[&str] = &[ + "var a = b / c / d;\nvar r = /ab+c/gi;\n", + "if (x) /re/.test(y);\nwhile (z) /q/g.exec(w);\n", + "function f() { return /x/; }\nvar g = function () {} / 2;\n", + "{}\n/re/.test(s);\n", + "x = {a: 1} / 2;\n", + "a = b\n/c/g\n", + "s = 'it\\'s \"q\" // not a comment';\nt = \"/* nor this */\";\n", + "s = 'line\\\ncontinued';\nu = 1;\n", + "x = a++ / 2;\ny = (b) / 2;\nz = c[0] / 2;\n", + "r = /[/]/;\nq = /\\//;\n", + "#target vectorcraft\n#include \"lib.jsxinc\"\n//@includepath \"~/x\"\nvar a = 1;\n#targetengine session\n", + "a = 1;\r\nb = 2;\r\nc = 3;", + "a = 1;\u{2028}b = 2;\u{2029}c = 3;\n", + "typeof /x/ === 'object';\nvoid /y/;\n", + "switch (x) { case /a/.test(y): break; }\n", + "x = y ? /a/ : /b/;\n", + "label: for (;;) { break label; }\n/* multi\nline */ z = 1 / 2;\n", + "var o = { re: /x/, d: 4 / 2 };\n", + "x = [/a/, 1 / 2];\n", + "if (a) {\n} else /x/.test(b);\n", + "a = 'q' + \"r\";\n// a comment with a 'quote\n", + "x = a\n++b\n", + "for (var i = 0; i < n / 2; i++) { s += /\\d/.exec(t[i]) ? 1 : 0; }\n", + "", + "\n\n", +]; + +/// Scripts with a string, comment or regular expression that is never closed: the problem the +/// checker gives and the line it starts on. +const INVALID: &[(&str, &str, usize)] = &[ + ("var a = 1;\nvar s = 'abc;\n", "unterminatedString", 2), + ("a = 1;\n/* never closed\nb = 2;\n", "unterminatedComment", 2), + ("x = 1;\ny = /abc\n", "unterminatedRegExp", 2), + ("if (x)\n /abc\n", "unterminatedRegExp", 2), + ("a = 1;\r\nb = \"x\r\n", "unterminatedString", 2), + ("q = 2 / 3;\nr = /[/]\n", "unterminatedRegExp", 2), +]; + +fn js_options() -> Options { + Options { preprocessor_lines: true, ..Options::default() } +} + +#[test] +fn both_lexers_count_the_same_lines_and_read_the_same_directives() { + for src in VALID { + let program = parse(src, &js_options()).unwrap_or_else(|e| panic!("{src:?}: {e}")); + let report = check("script.jsx", src.as_bytes(), None, &Limits::default()); + assert_eq!(report.lines, program.line_count() as usize, "lines of {src:?}"); + let lexical: Vec<_> = report.problems.iter().filter(|p| LEXICAL.iter().any(|(code, _)| *code == p.code)).collect(); + assert!(lexical.is_empty(), "{src:?}: the checker reports {lexical:?}"); + let theirs: Vec<(String, usize)> = report.directives.iter().map(|d| (d.name.to_string(), d.line)).collect(); + let ours: Vec<(String, usize)> = program.directives.iter().map(|d| (d.name.to_lowercase(), d.line as usize)).collect(); + assert_eq!(ours, theirs, "directives of {src:?}"); + } +} + +#[test] +fn both_lexers_find_the_same_unclosed_token_on_the_same_line() { + for (src, code, line) in INVALID { + let report = check("script.jsx", src.as_bytes(), None, &Limits::default()); + let theirs = report.problems.iter().find(|p| LEXICAL.iter().any(|(c, _)| *c == p.code)); + let theirs = theirs.unwrap_or_else(|| panic!("{src:?}: the checker reports no lexical problem")); + assert_eq!((theirs.code, theirs.line), (*code, Some(*line)), "{src:?}"); + let ours = parse(src, &js_options()).expect_err(src); + let message = LEXICAL.iter().find(|(c, _)| c == code).map(|(_, m)| *m).unwrap(); + assert!(ours.message.contains(message), "{src:?}: {}", ours.message); + assert_eq!(ours.line as usize, *line, "{src:?}"); + } +} + +#[test] +fn the_parser_reads_a_regexp_wherever_the_checker_does() { + // Each `/` that divides has no second `/` after it on its line, so a lexer that read one as a + // regular expression would report it as never closed. The checker reports nothing, and the + // parser reads regular expressions on lines 2 and 3 only. + let src = "a = b / c;\nr = /x/;\nif (a) /y/.test(b);\nz = (1) / 2;\n"; + let tokens = tokenize(src, &js_options()).unwrap(); + let lines: Vec = tokens.iter().filter(|t| matches!(t.kind, TokenKind::RegExp { .. })).map(|t| t.line).collect(); + assert_eq!(lines, [2, 3]); + let report = check("script.jsx", src.as_bytes(), None, &Limits::default()); + assert!(report.problems.iter().all(|p| !LEXICAL.iter().any(|(c, _)| *c == p.code)), "{:?}", report.problems); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f17d27e4d..3689d9cae 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -18,10 +18,12 @@ mod dimensions; mod eyedropper_sources; mod find_replace_paras; mod inline_text; +mod js_lexer_vs_scriptcheck; mod known_bugs; mod lossy_overwrite; mod model_based; mod param_keys; +mod paste_many; mod pen_click_after_smooth; mod pen_endpoint_picking; mod ruler_origin; diff --git a/crates/engine/tests/it/param_keys.rs b/crates/engine/tests/it/param_keys.rs index 3da393ca8..25814357e 100644 --- a/crates/engine/tests/it/param_keys.rs +++ b/crates/engine/tests/it/param_keys.rs @@ -1,24 +1,112 @@ -//! A caller's params are checked against the command's description, but a journal replays as it -//! was recorded (#1442). +//! `Session::execute` checks params against the command's description, but a journal replays as +//! it was recorded (#1442). use serde_json::json; use vectorcraft_engine::{Session, check_params}; -/// `Session::execute` takes what it is given: the journal of an older version, an action recorded -/// with a key a command no longer reads, still plays. The check is the caller's. +/// The open document's art and undo history, and the session's journal length. +fn state(s: &Session) -> (Option, usize, usize) { + let st = s.active(); + (st.map(|st| (*st.doc).clone()), st.map_or(0, |st| st.history.undo.len()), s.journal.len()) +} + +/// `Session::execute` answers a key the command's description doesn't name with a parameter +/// error, in a release build too, and the document, its undo history and the journal stay as +/// they were. +#[test] +fn execute_refuses_a_key_its_command_does_not_name() { + let mut s = Session::new(); + let e = s.execute("file.new", &json!({"width": 300, "widht": 1})).unwrap_err().to_string(); + assert!(e.contains("widht"), "{e}"); + assert!(s.active().is_none()); + s.execute("file.new", &json!({})).unwrap(); + let before = state(&s); + let e = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40, "cornerStyle": "round"})).unwrap_err(); + assert!(e.to_string().contains("cornerStyle"), "{e}"); + assert!(state(&s) == before); +} + +/// A step of a `command.batch` with such a key fails the batch before any step runs, and a step +/// whose `effect.setParams` gives an effect a parameter it doesn't take fails it too, though that +/// effect is only there once the steps before it ran. Nothing the batch did stays. +#[test] +fn execute_refuses_a_batch_step_with_a_key_its_command_does_not_name() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = state(&s); + let rect = json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}}); + let bad_step = json!({"commands": [rect, {"command": "object.move", "params": {"dx": 5, "dyy": 5}}]}); + let e = s.execute("command.batch", &bad_step).unwrap_err().to_string(); + assert!(e.contains("dyy"), "{e}"); + assert!(state(&s) == before); + let bad_effect = json!({"commands": [ + rect, + {"command": "effect.apply", "params": {"effect": "blur.gaussian"}}, + {"command": "effect.setParams", "params": {"index": 0, "params": {"blurr": 3}}}, + ]}); + assert!(check_params("command.batch", &bad_effect).is_ok()); + let e = s.execute("command.batch", &bad_effect).unwrap_err().to_string(); + assert!(e.contains("blurr"), "{e}"); + assert!(state(&s) == before); +} + +/// The steps of a `command.batch` step aren't checked, however deep they go: batches can't nest, +/// which running the batch reports. +#[test] +fn the_steps_of_a_nested_batch_are_not_checked() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let mut batch = json!({"commands": [{"command": "object.move", "params": {"dyy": 5}}]}); + assert!(check_params("command.batch", &batch).is_err()); + for _ in 0..3 { + batch = json!({"commands": [{"command": "command.batch", "params": batch}]}); + } + assert!(check_params("command.batch", &batch).is_ok()); + let e = s.execute("command.batch", &batch).unwrap_err().to_string(); + assert!(e.contains("batches cannot nest"), "{e}"); +} + +/// An `action.batch` with a step that has such a key fails before it opens a source file or runs +/// a step. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn execute_refuses_an_action_batch_step_with_a_key_its_command_does_not_name() { + let dir = std::env::temp_dir().join(format!("vc-param-keys-action-batch-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let svg = r#""#; + std::fs::write(dir.join("a.svg"), svg).unwrap(); + let mut s = Session::new(); + let batch = json!({ + "source": "folder", + "folder": dir.to_string_lossy(), + "commands": [{"command": "select.all", "params": {}}, {"command": "object.move", "params": {"dx": 5, "dyy": 5}}], + }); + let e = s.execute("action.batch", &batch).unwrap_err().to_string(); + std::fs::remove_dir_all(&dir).ok(); + assert!(e.contains("dyy"), "{e}"); + assert!(s.documents().is_empty()); + assert!(s.journal.is_empty()); +} + +/// `Session::replay` takes what it is given: the journal of an older version, an action recorded +/// with a key a command no longer reads, still plays, as a step of a `command.batch` too. #[test] fn a_journal_with_a_key_no_longer_read_still_replays() { let old = [ ("file.new", json!({"width": 300, "height": 200, "widht": 1})), ("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40, "cornerStyle": "round"})), + ("command.batch", json!({"commands": [{"command": "object.move", "params": {"dx": 5, "dy": 5, "dyy": 5}}]})), ]; let mut s = Session::new(); for (id, params) in &old { assert!(check_params(id, params).is_err(), "{id}"); - s.execute(id, params).unwrap(); + s.replay(id, params).unwrap(); } + assert!(!s.replaying()); let st = s.active().unwrap(); assert_eq!((st.doc.artboards[0].rect.width(), st.doc.layers[0].children().unwrap().len()), (300.0, 1)); + assert_eq!(s.journal.len(), 3); + assert!(s.execute("object.move", &json!({"dx": 5, "dyy": 5})).is_err()); } /// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't @@ -34,7 +122,8 @@ fn effect_params_no_longer_taken_still_replay_and_open() { ]; for (id, params) in &old { assert!(s.check_params(id, params).is_err(), "{id}"); - s.execute(id, params).unwrap(); + assert!(s.execute(id, params).is_err(), "{id}"); + s.replay(id, params).unwrap(); } let doc = &s.active().unwrap().doc; let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); diff --git a/crates/engine/tests/it/paste_many.rs b/crates/engine/tests/it/paste_many.rs new file mode 100644 index 000000000..0482b25c0 --- /dev/null +++ b/crates/engine/tests/it/paste_many.rs @@ -0,0 +1,209 @@ +//! Paste of many objects: what lands in the document (nodes, ids, names, order, selection, one undo +//! step), and the shortcuts the paste takes for objects that have nothing to relink or rename. + +use std::collections::HashSet; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use vectorcraft_engine::Session; +use vectorcraft_geom::Point; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap() +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ids_of(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + id_of(&run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 8, "height": 8}))) +} + +/// Three named groups of three named rectangles each, on the document's only layer. +fn named_groups() -> Session { + let mut doc = Document::new(400.0, 400.0); + let layer = doc.layers[0].id; + for g in 0..3 { + let group = doc.alloc_id(); + let mut node = Node::group(group, vec![]); + node.name = Some(format!("group {g}")); + doc.insert(Some(layer), usize::MAX, node).unwrap(); + for r in 0..3 { + let x = f64::from(g * 3 + r) * 10.0; + let shape = vectorcraft_geom::rect_from_points(Point::new(x, 0.0), Point::new(x + 8.0, 8.0)); + let appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0); + let id = doc.alloc_id(); + let mut node = Node::path(id, vectorcraft_geom::shapes::rectangle(shape), appearance); + node.name = Some(format!("rect {g}.{r}")); + doc.insert(Some(group), usize::MAX, node).unwrap(); + } + } + let mut s = Session::new(); + s.add_document(doc, None); + s +} + +/// `n` and its descendants in tree order, as (id, name). +fn tree(n: &Node) -> Vec<(NodeId, Option)> { + let mut out = vec![]; + n.walk(&mut |c| out.push((c.id, c.name.clone()))); + out +} + +#[test] +fn a_paste_of_groups_gives_the_same_nodes_new_ids_in_tree_order_and_one_undo_step() { + let mut s = named_groups(); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + let st = s.doc().unwrap(); + let (first_id, steps, before) = (st.doc.peek_next_id(), st.history.undo.len(), st.doc.clone()); + let sources: Vec = st.selection.objects.clone(); + + let pasted = run(&mut s, "edit.paste", json!({"dx": 5, "dy": 7})); + let ids = ids_of(&pasted); + let st = s.doc().unwrap(); + + // The copies, on top of the layer in the order of their sources, are selected. + assert_eq!(ids.len(), sources.len()); + assert_eq!(st.selection.objects, ids); + let top: Vec = st.doc.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(top[sources.len()..], ids[..]); + assert_eq!(top[..sources.len()], sources[..]); + + // Names and geometry match the sources; ids run on from the counter in tree order. + let mut next = first_id; + for (source, copy) in sources.iter().zip(&ids) { + let (source, copy) = (st.doc.node(*source).unwrap(), st.doc.node(*copy).unwrap()); + let (a, b) = (tree(source), tree(copy)); + assert_eq!(a.len(), b.len()); + for ((_, name_a), (id_b, name_b)) in a.iter().zip(&b) { + assert_eq!(name_a, name_b); + assert_eq!(id_b.0, next); + next += 1; + } + let (from, to) = (source.geometric_bounds().unwrap(), copy.geometric_bounds().unwrap()); + assert!((to.x0 - from.x0 - 5.0).abs() < 1e-9 && (to.y0 - from.y0 - 7.0).abs() < 1e-9, "{from:?} {to:?}"); + } + assert_eq!(st.doc.peek_next_id(), next); + + // One undo step, back to the document as it was. + assert_eq!(st.history.undo.len(), steps + 1); + run(&mut s, "edit.undo", json!({})); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), steps); + assert_eq!(*st.doc, *before); +} + +#[test] +fn pasted_type_in_a_group_gets_its_layout_bounds() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let svg = r#"WWWMMM"#; + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + run(&mut s, "edit.paste", json!({})); + let mut checked = 0; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + assert!(t.cached_bounds.is_some(), "{:?} has no layout bounds", n.id); + checked += 1; + } + }); + assert_eq!(checked, 2); +} + +/// A group holding point type and a rectangle: (the group, the type). +fn group_with_type() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let text = id_of(&run(&mut s, "text.create", json!({"x": 10, "y": 40, "text": "a", "size": 12}))); + let other = rect(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [text.0, other.0]})); + let group = id_of(&run(&mut s, "object.group", json!({}))); + (s, group, text) +} + +#[test] +fn find_and_replace_given_a_group_and_type_in_it_changes_that_type_once() { + let (mut s, group, text) = group_with_type(); + // Replacing "a" with "aa" twice would leave "aaaa". + run(&mut s, "edit.findReplace", json!({"find": "a", "replace": "aa", "ids": [group.0, text.0, group.0]})); + let doc = &s.doc().unwrap().doc; + let NodeKind::Text(t) = &doc.node(text).unwrap().kind else { panic!("the type is gone") }; + assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::(), "aa"); +} + +#[test] +fn a_type_command_given_a_group_and_type_in_it_lists_that_type_once() { + let (mut s, group, text) = group_with_type(); + let changed = run(&mut s, "type.orientation.vertical", json!({"ids": [group.0, text.0, group.0]})); + assert_eq!(ids_of(&changed), vec![text]); +} + +#[test] +fn a_pasted_objects_on_path_group_names_its_own_copies() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 40.0)); + let path = id_of(&run(&mut s, "shape.line", json!({"x1": 0, "y1": 100, "x2": 300, "y2": 100}))); + let group = id_of(&run(&mut s, "object.objectsOnPath.attach", json!({"path": path.0, "ids": [a.0, b.0]}))); + + // Inside another group too: every level of a copy gets new ids. + let plain = rect(&mut s, 200.0); + run(&mut s, "select.set", json!({"ids": [group.0, plain.0]})); + let outer = id_of(&run(&mut s, "object.group", json!({}))); + run(&mut s, "select.set", json!({"ids": [outer.0]})); + run(&mut s, "edit.copy", json!({})); + let pasted = ids_of(&run(&mut s, "edit.paste", json!({}))); + + let doc = &s.doc().unwrap().doc; + let copy = doc.node(pasted[0]).unwrap().children().unwrap().iter().find(|c| c.extras().objects_on_path.is_some()).unwrap(); + let rec = copy.extras().objects_on_path.as_deref().unwrap(); + let children: HashSet = copy.children().unwrap().iter().map(|c| c.id).collect(); + assert!(children.contains(&rec.path), "the path {:?} is not in the copy", rec.path); + assert_ne!(rec.path, path); + assert_eq!(rec.members.len(), 2); + for m in &rec.members { + assert!(children.contains(&m.id), "the member {:?} is not in the copy", m.id); + } +} + +#[test] +fn a_pasted_object_keeps_its_graphic_style_under_the_id_it_gets_in_the_document() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let source = rect(&mut s, 10.0); + run(&mut s, "paint.setFill", json!({"ids": [source.0], "color": "#ff0000"})); + run(&mut s, "graphicStyle.new", json!({"name": "Copied", "id": source.0})); + run(&mut s, "select.set", json!({"ids": [source.0]})); + run(&mut s, "edit.copy", json!({})); + + // A second document whose own style already has the id the copied one has. + run(&mut s, "file.new", json!({"width": 500, "height": 200})); + let own = rect(&mut s, 10.0); + run(&mut s, "paint.setFill", json!({"ids": [own.0], "color": "#0000ff"})); + run(&mut s, "graphicStyle.new", json!({"name": "Own", "id": own.0})); + let pasted = ids_of(&run(&mut s, "edit.paste", json!({}))); + + let doc = &s.doc().unwrap().doc; + let link = doc.node(pasted[0]).unwrap().extras().graphic_style; + let copied = doc.graphic_styles.iter().find(|g| g.name == "Copied").unwrap().id; + assert_ne!(copied, doc.graphic_styles.iter().find(|g| g.name == "Own").unwrap().id); + assert_eq!(link, Some(copied)); + assert_eq!(doc.node(own).unwrap().extras().graphic_style, doc.graphic_styles.iter().find(|g| g.name == "Own").map(|g| g.id)); +} + +#[test] +fn a_pasted_object_without_a_graphic_style_stays_without_one() { + let mut s = named_groups(); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + for id in ids_of(&run(&mut s, "edit.paste", json!({}))) { + s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| assert_eq!(n.extras().graphic_style, None)); + } +} diff --git a/crates/engine/tests/open_kept_layouts.rs b/crates/engine/tests/open_kept_layouts.rs new file mode 100644 index 000000000..a65e0dfc3 --- /dev/null +++ b/crates/engine/tests/open_kept_layouts.rs @@ -0,0 +1,279 @@ +//! A document of type opened and drawn with the layouts the open keeps, compared with the same +//! document drawn after laying every object out again: the nodes, bounds, baselines and pixels are +//! the same, and the first frame lays out only what it could not take. Its own test binary: kept +//! layouts and the font database's generation belong to the whole process. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::{Mutex, MutexGuard}; + +use serde_json::{Value, json}; +use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject, TextRun}; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::fileio; +use vectorcraft_geom::{Affine, Point}; +use vectorcraft_render::{RenderOptions, Renderer}; +use vectorcraft_text::test_fonts::{drop_kept_layouts, layouts_on_this_thread}; +use vectorcraft_text::{FontDb, KeptLayoutsFrame}; + +const WIDTH: u32 = 900; +const HEIGHT: u32 = 1300; + +/// Taken by each test: kept layouts belong to the whole process. +static PROCESS: Mutex<()> = Mutex::new(()); + +fn process() -> MutexGuard<'static, ()> { + PROCESS.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn style() -> CharStyle { + CharStyle { size: 40.0, ..CharStyle::default() } +} + +fn file_of(objects: Vec) -> Vec { + let mut doc = Document::new(f64::from(WIDTH), f64::from(HEIGHT)); + let layer = doc.layers[0].id; + for (i, mut t) in objects.into_iter().enumerate() { + t.xf = Affine::translate((20.0, 70.0 * (i + 1) as f64)); + let id = doc.alloc_id(); + doc.insert(Some(layer), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + } + vectorcraft_format::save_file(&doc) +} + +fn latin() -> Vec { + file_of(["Plain Latin type", "AVATAR office", "123 ... 456"].map(|s| TextObject::point(Point::ZERO, s, style())).into()) +} + +fn mixed_fonts() -> Vec { + let font = |family: &str, text: &str| TextRun::new(text, CharStyle { font_family: family.into(), ..style() }); + let mut mixed = TextObject::point(Point::ZERO, "", style()); + mixed.runs = vec![font("Inter", "Inter "), font("JetBrains Mono", "Mono "), font("Source Serif 4", "Serif "), font("No Such Family", "Missing")]; + file_of(vec![mixed, TextObject::point(Point::ZERO, "Source Sans", style())]) +} + +fn complex_scripts() -> Vec { + let scripts = [ + "\u{645}\u{631}\u{62d}\u{628}\u{627}", + "\u{5e9}\u{5dc}\u{5d5}\u{5dd}", + "\u{928}\u{92e}\u{938}\u{94d}\u{924}\u{947}", + "\u{e2a}\u{e27}\u{e31}\u{e2a}\u{e14}\u{e35}", + "\u{65e5}\u{672c}\u{8a9e} abc", + ]; + file_of(scripts.map(|s| TextObject::point(Point::ZERO, s, style())).into()) +} + +/// Area type, and a story through two frames, as a session saves them. +fn threaded_story() -> Vec { + let mut s = Session::new(); + s.add_document(Document::new(f64::from(WIDTH), f64::from(HEIGHT)), None); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute( + "text.create", + &json!({"x": 10, "y": 300, "text": "Area type on its own. ".repeat(20), "size": 12, "area": {"width": 300, "height": 200}}), + ) + .unwrap(); + vectorcraft_format::save_file(&s.doc().unwrap().doc) +} + +fn documents() -> Vec<(&'static str, Vec)> { + vec![("Latin", latin()), ("mixed fonts", mixed_fonts()), ("complex scripts", complex_scripts()), ("threaded story", threaded_story())] +} + +/// The session `bytes` opened in, as `document.open` reads a file and then opens it. +fn open(bytes: &[u8]) -> Session { + let name = "layouts.vectorcraft"; + let mut s = Session::new(); + let read = fileio::read_for_open(name, bytes, Some(name.into()), &Value::Null, false, false).unwrap(); + fileio::open_read(&mut s, read).unwrap(); + s +} + +fn frame(doc: &Document) -> Vec { + Renderer::new().render(doc, WIDTH, HEIGHT, Affine::IDENTITY, &RenderOptions::default()).pixels +} + +fn type_objects(doc: &Document) -> Vec { + let mut all = vec![]; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + all.push((**t).clone()); + } + }); + all +} + +/// Open once to load every font the document uses: a load changes the font database's generation. +fn fonts_loaded(bytes: &[u8]) { + let s = open(bytes); + frame(&s.doc().unwrap().doc); + drop_kept_layouts(); +} + +#[test] +fn the_first_frame_draws_the_pixels_of_laying_every_object_out_again() { + let _process = process(); + for (name, bytes) in documents() { + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let taken = frame(doc); + drop_kept_layouts(); + let again = frame(doc); + assert!(again != frame(&Document::new(f64::from(WIDTH), f64::from(HEIGHT))), "{name}: the frame shows the type"); + let differing = taken.iter().zip(&again).filter(|(a, b)| a != b).count(); + assert_eq!((taken.len(), differing), (again.len(), 0), "{name}: bytes of the frame that differ"); + } +} + +#[test] +fn the_kept_layouts_are_the_layouts_of_the_opened_objects() { + let _process = process(); + for (name, bytes) in documents() { + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let mut taken = 0; + let frame_drawn = KeptLayoutsFrame::begin(); + for t in type_objects(doc) { + let fresh = vectorcraft_text::layout(FontDb::global(), &t); + assert_eq!(t.cached_bounds, Some(fresh.bounds), "{name}: {:?}", t.plain_text()); + assert_eq!(t.cached_baselines, fresh.baselines(), "{name}: {:?}", t.plain_text()); + if let Some(kept) = vectorcraft_text::take_kept_layout(&t) { + let outlines = + |l: &vectorcraft_text::TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.origin, g.outline.clone())).collect::>(); + assert!(outlines(&kept) == outlines(&fresh), "{name}: {:?}", t.plain_text()); + assert_eq!(kept.bounds, fresh.bounds, "{name}: {:?}", t.plain_text()); + taken += 1; + } + } + assert!(taken > 0, "{name}: the open kept layouts"); + drop(frame_drawn); + drop_kept_layouts(); + let again = open(&bytes); + assert!(*again.doc().unwrap().doc == **doc, "{name}: another open reads the same document"); + drop_kept_layouts(); + } +} + +#[test] +fn the_first_frame_lays_out_no_object_the_open_laid_out() { + let _process = process(); + let bytes = latin(); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 0, "the frame takes every layout"); + // A second renderer has nothing left to take. + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 3); +} + +#[test] +fn an_edited_object_is_laid_out_again() { + let _process = process(); + let bytes = latin(); + fonts_loaded(&bytes); + let mut s = open(&bytes); + let id = { + let mut ids = vec![]; + s.doc().unwrap().doc.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + ids.push(n.id.0); + } + }); + ids[0] + }; + s.execute("text.setText", &json!({"id": id, "text": "Edited"})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let before = layouts_on_this_thread(); + let edited = frame(&doc); + assert_eq!(layouts_on_this_thread() - before, 1, "only the edited object is laid out"); + drop_kept_layouts(); + assert!(edited == frame(&doc), "the frame is what laying every object out again draws"); +} + +#[test] +fn changing_the_hyphenation_exceptions_or_the_fonts_lays_every_object_out_again() { + let _process = process(); + let bytes = latin(); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + vectorcraft_text::set_hyphenation_exceptions(""); + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 3, "hyphenation exceptions were set"); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + FontDb::global().add_font(vectorcraft_text::test_fonts::variable_font().unwrap()); + let generation = FontDb::global().generation(); + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 3, "fonts were added (generation {generation})"); +} + +/// The layout of an object the first frame culls is dropped when that frame ends. +#[test] +fn a_frame_lets_go_of_what_it_did_not_draw() { + let _process = process(); + let mut objects = vec![TextObject::point(Point::ZERO, "In view", style())]; + objects.extend((0..3).map(|i| TextObject::point(Point::ZERO, &format!("Out of view {i}"), style()))); + let bytes = file_of(objects); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let small = Renderer::new().render(doc, 300, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(small.width, 300); + let out = type_objects(doc).into_iter().find(|t| t.plain_text() == "Out of view 2").unwrap(); + let _frame = KeptLayoutsFrame::begin(); + assert!(vectorcraft_text::take_kept_layout(&out).is_none()); +} + +/// A thumbnail lays its object out, and the first frame still takes that object's layout. +#[test] +fn a_thumbnail_leaves_the_layouts_for_the_first_frame() { + let _process = process(); + let bytes = latin(); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let mut ids = vec![]; + doc.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + ids.push(n.id); + } + }); + let before = layouts_on_this_thread(); + assert!(Renderer::new().render_thumbnail(doc, ids[0], 64).is_some()); + assert_eq!(layouts_on_this_thread() - before, 1, "the thumbnail lays its object out"); + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 0, "the frame takes every layout"); +} + +/// A frame of ink amounts ends like any other. +#[test] +fn an_ink_frame_lets_go_of_what_it_did_not_draw() { + let _process = process(); + let mut objects = vec![TextObject::point(Point::ZERO, "In view", style())]; + objects.extend((0..3).map(|i| TextObject::point(Point::ZERO, &format!("Out of view {i}"), style()))); + let bytes = file_of(objects); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let region = vectorcraft_geom::Rect::new(0.0, 0.0, 300.0, 100.0); + assert!(!Renderer::new().render_region_inks(doc, region, 1.0, &RenderOptions::default()).is_empty()); + let out = type_objects(doc).into_iter().find(|t| t.plain_text() == "Out of view 2").unwrap(); + let _frame = KeptLayoutsFrame::begin(); + assert!(vectorcraft_text::take_kept_layout(&out).is_none()); +} diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index ac982a20e..1791555f7 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -268,4 +268,98 @@ proptest! { } renders(&s, "after mutated modules")?; } + + /// Manifests with hostile `permissions`, `api` and names (unknown, reserved and repeated + /// permissions, junk of every type, control and bidirectional override characters) parse to + /// an error or to a manifest whose permissions fit its kind, and installing one with a junk + /// `grant` ends as an error or an install, never a panic. A prompt never shows a hidden + /// character from the name. + #[test] + fn hostile_manifest_permissions_never_crash( + kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + names in prop::collection::vec( + prop_oneof![ + prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "progress", "ui.panel", ""]), + Just("doc.read\u{202E}"), + Just("\u{0007}"), + ], + 0..6, + ), + permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), + api in prop_oneof![2 => Just(json!(1)), 1 => Just(json!(2)), 1 => arb_junk()], + name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"], + grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "progress"]))], + ) { + let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api}); + if kind == "exporter" { + m["extensions"] = json!(["fz"]); + } + m["permissions"] = match permissions { + Some(Value::Null) | None => json!(names), + Some(junk) => junk, + }; + let text = m.to_string(); + let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); + let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; + if let Ok(man) = &parsed { + prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", text); + let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); + prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + } + let mut s = rich_session(); + let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&module); + let r = catch_quiet(|| { + let _ = s.execute("plugin.inspect", &json!({"dataBase64": data})); + s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant})).is_ok() + }); + let installed = r.map_err(|msg| TestCaseError::fail(format!("panicked installing {text}: {msg}")))?; + prop_assert!(!installed || parsed.is_ok(), "a manifest that doesn't parse installed: {}", text); + vectorcraft_plugins::registry::remove("fuzz.permissions"); + vectorcraft_plugins::registry::revoke("fuzz.permissions"); + } + + /// A plug-in of API level 2 that calls the host imports with hostile arguments (any progress + /// counts, any log level, pointers and lengths in and out of its memory, text that isn't + /// UTF-8) runs to an error or an output, never a panic, and its report stays within the caps. + #[test] + fn hostile_host_import_arguments_never_crash( + calls in prop::collection::vec( + prop_oneof![ + (any::(), any::()).prop_map(|(d, t)| format!("(call $vc_progress (i64.const {d}) (i64.const {t}))")), + Just("(drop (call $vc_cancelled))".to_string()), + (prop_oneof![any::(), -1..5i32], prop_oneof![any::(), 0x1ff00..0x20100i32], prop_oneof![any::(), 0..5000i32]) + .prop_map(|(l, p, n)| format!("(drop (call $vc_log (i32.const {l}) (i32.const {p}) (i32.const {n})))")), + ], + 0..24, + ), + bytes in prop::collection::vec(any::(), 0..64), + effect in any::(), + ) { + use vectorcraft_plugins::host::{LOG_TRUNCATED, MAX_LOG_BYTES, MAX_LOG_LINE, MAX_LOG_LINES}; + let manifest = if effect { + r#"{"id":"fuzz.host","name":"F","kind":"effect","api":2,"permissions":["doc.read","effect.render","progress"]}"# + } else { + r#"{"id":"fuzz.host","name":"F","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}"# + }; + let text: String = bytes.iter().map(|b| format!("\\{b:02x}")).collect(); + let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::HOST_IMPORTS); + let wasm = wat::parse_str(tpl::with_imports(manifest, &imports, &calls.concat(), r#"{"objects":[]}"#)).unwrap(); + let r = catch_quiet(|| { + let p = vectorcraft_plugins::Plugin::load(&wasm, vectorcraft_plugins::Limits::default())?; + let token = vectorcraft_doc::cancel::CancelToken::new(); + let sink: vectorcraft_doc::cancel::ProgressSink = std::rc::Rc::new(|_, _, _| {}); + Ok::<_, vectorcraft_plugins::Error>(vectorcraft_doc::cancel::scope(&token, Some(sink), || p.run_reporting(b"{\"objects\":[]}", b"{}", p.limits()))) + }); + let r = r.map_err(|msg| TestCaseError::fail(format!("panicked on {calls:?}: {msg}")))?; + let (out, report) = r.map_err(|e| TestCaseError::fail(format!("refused at load: {e}")))?; + prop_assert!(out.is_ok(), "{:?}", out); + prop_assert!(report.lines.len() <= MAX_LOG_LINES + 1); + prop_assert!(report.lines.iter().all(|l| l.text.chars().count() <= MAX_LOG_LINE)); + prop_assert!(report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum::() <= MAX_LOG_BYTES); + if let Some((done, total)) = report.progress { + prop_assert!(total == 0 || done <= total); + } + } } diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 0004a65d3..eac7b16b2 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -21,6 +21,8 @@ vectorcraft-text = { workspace = true } vectorcraft-metafile = { workspace = true } # Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF). vectorcraft-pdf = { workspace = true } +# The predefined CMaps that strings of legacy type in CJK fonts are decoded by. +craft-pdf = { workspace = true } # The TIFF writer (previews) and PackBits, shared with raster export. vectorcraft-render = { workspace = true } kurbo = { workspace = true } diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 2ad1c719c..828145c9c 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -28,6 +28,27 @@ //! `0 (name) count Pg` before its swatches, `PB`), then the artboards as the `ArtboardArray` of the //! `/Document` dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on //! `%_` lines. +//! - Variables (`writer::variables`): after the canvas size, the variable library as XML nodes +//! (`/XMLNode`: `xmlnode-nodename`, `xmlnode-nodevalue`, `xmlnode-nodetype`, `xmlnode-children`, +//! `xmlnode-attributes`) of an XML document `#document`: a `metadata` element holding +//! `variableSets`, one `variableSet` (`locked`: `none`, or `variableAndTypes` when the variables +//! are locked), its `variables` (a `variable` element with `varName` and `trait`: `textcontent`, +//! `visibility`, `fileref` or `graphdata`, none for a variable of unknown kind; a variable bound to +//! objects declared once for each, its `docRef` the object's XML id, `/XMLUIDREF`) and its +//! `v:sampleDataSets` (`i:activeDataSet`, the data set shown) of `v:sampleDataSet` elements +//! (`dataSetName`), each holding an element named after each variable it gives a value: a `p` +//! element for each line of a text value, a `/Bool` text whether the objects show, a linked file's +//! `file://` URL or path. An object bound to a variable without a name gets an XML id and an empty +//! `AIArtName`. Bindings to layers, to type written as outlines, to graphs and to linked images +//! written as other objects, and graph values, values that don't fit their variable, linked files +//! that aren't absolute paths, paths from the root that the reader reads as other paths (`/` +//! alone, `/C:…`), drive and network paths with white space at either end, values of variables +//! whose names have a colon, and a data set shown that isn't written, are left out, as is what +//! the reader leaves out +//! (variables and data sets whose names are empty, repeated, longer than 255 characters or start +//! or end with white space, variables past 256, data sets past 1,000, bindings past 100,000 or to XML ids longer than +//! 255 characters, names and values past 8,388,608 bytes in all), and values past 200,000 text +//! lines and values in all. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0 //! dimPercent clipping Lb` (14 operands; a color that isn't one of the preset layer colors has the //! index -1; `clipping` is 1 for a clipping layer), `(name) Ln`, the layer's name as an XML id in an @@ -77,7 +98,8 @@ //! a preview of at most 256 pixels a side between `%AI26_BeginPlacedObjectPreview` and its end //! (`w h rowBytes 32 17 `, then rows of premultiplied ARGB), each in versioned content, `~`, //! `%AI5_EndPlace`. The box is the file's pixels at art space's origin on the canvas; the matrix -//! takes it into art space. In opacity masks and pattern tiles, linked images are embedded. +//! takes it into art space. In opacity masks, pattern tiles and symbols, linked images are +//! embedded. //! - Type: a text object `/AI11Text :` naming its story and frame (`/StoryIndex`, `/FrameIndex`), //! for area type and type on a path with its frame's path (`/Art :` … `; /ConfiningPath ,`), and //! the text document after the document data (`%AI11_BeginTextDocument`, see @@ -86,30 +108,66 @@ //! the alignment, indents, spacing, hyphenation and direction of each paragraph, and the frame: //! point type's point and matrix, area type's frame, rows, columns, gutters and first baseline, //! type on a path's path and span, vertical type, and the frames a threaded story flows through. +//! - Symbols, as the reader reads them ([`crate::import`]'s `.ai` reader, `read.rs`): every symbol +//! of the document defined in the setup after the patterns, in a non-printing block +//! (`%AI14_BeginSymbol`, `(name)`, `0 A`, `0 Xw`, `0 Ae`, the art in the symbol's space with y +//! down, the registration point as `AI13PatternRegistrationType` 1 to 9 from the top left, row by +//! row (VectorCraft's numbering, in the order the scripting reference lists the points), in the +//! art's dictionary, `%AI10_EndSymbol`), each after those its art uses; the symbol +//! list after the palette (`%AI24_BeginSymbolList`, the names in the Symbols panel's order, +//! `%AI24_EndSymbolList`); each instance as `/SymbolInstance :`, `(name) /SymbolRef ,`, the +//! matrix taking the art to art space as `a b c d tx ty /RTransformMatrix`, `;`, then its name +//! and opacity mask and a state block as a group's with its opacity and blending mode (an +//! instance with isolation, knockout or knockout shape of its own is written as its art). The +//! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and +//! a gradient's take the art to the canvas's space turned y up about art space's origin there. +//! - Art styles, as the reader reads them (`style.rs`, `read/ops.rs`): a path, compound path or +//! group with live effects is written as its drawn look (`u`, its vector art as its geometry +//! effects leave it, without shadows, glows or feathers, `U`, its transparency, `0 0 Xd`, +//! `6 () XW`), then the object itself on `%_` lines (its points painted +//! with nothing, or with its gradient fill; a group with its members) with its name and opacity +//! mask, then `1 (style) XW`. The styles are written in a non-printing block after the symbols +//! (`%AI9_BeginArtStyles`, each `/KnownStyle :` with its name and a filter tree: a chain of the +//! stack of fills and strokes with the contents among them, the effects and the blend filter). A +//! fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that fills by +//! the even-odd rule is written as its drawn look. A style is written only when the reader reads it +//! back as the object's appearance. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the //! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and //! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such -//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles and art cut to -//! a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby, +//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles, symbols and +//! art cut to a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby, //! gradient or pattern paint, an appearance of its own) as its outlines and its inline graphics' //! art, the character and paragraph attributes the text document doesn't hold, freeform gradients, gradients //! along or across a stroke and gradients without stops, with more than 256 stops or without extent //! as their average color //! (gradients that dither or mix perceptually are written as classic ones, and gradient stops linked //! to global process colors as their colors), pattern fills whose tiles aren't a grid, whose art reaches past -//! the tile or is painted with patterns as their tiles cut to the shape, clipping groups and clipping layers whose clipping object isn't a path, +//! the tile or is painted with patterns, and pattern fills in symbols, as their tiles cut +//! to the shape, clipping groups and clipping layers whose clipping object isn't a path, //! and the clipping groups inside them, as their art cut to that object (its fill cut too, its //! stroke not; an image in a clipping group of the region it is cut to), images that can't be //! decoded or have more than 33,554,432 pixels as their outlines filled mid gray, images of 16 bits -//! a channel at 8 bits, linked images in opacity masks and pattern tiles as embedded images, -//! symbols and live objects (placed documents included) as their art (symbol art past -//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw +//! a channel at 8 bits, linked images in opacity masks, pattern tiles and symbols as embedded images, +//! symbol instances with a fill, appearance changes, isolation, knockout or knockout shape of their +//! own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a +//! clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding +//! type, live objects, instances written as their art, shapes with rounded live corners or strokes +//! written as outlines or brush art, +//! instances that scale their symbol more than 100 times, instances on the layers of a large canvas +//! and instances of +//! symbols past 4,096 as their art (symbol art past [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects +//! in all left out), instances of symbols the document doesn't have as empty groups, symbols' 9-slice +//! guides, export types and dynamic type left out, live objects (placed documents included) as their art, +//! strokes a plain line can't draw //! as their filled outlines, several fills or strokes as a group of a path for each, freeform //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on -//! layers and opacity masks nested too deep, and live effects other than geometry effects left out. +//! layers and opacity masks nested too deep, and, on objects written without an art style, live +//! effects other than geometry effects left out; graphic styles, links to them and every art style +//! on a large canvas are left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -186,11 +244,15 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); } + // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. + let scale = canvas_scale(doc); + // The objects that may keep their effects in an art style, as they are before baking (not on + // a large canvas, whose scale the styles' strokes and effects would need). + let styled = writer::styled_objects(doc); + let graphic_styles = writer::has_graphic_styles(doc); // Live geometry effects as their result, as the PDF part draws them. let baked = baked(doc); let doc = baked.as_ref().unwrap_or(doc); - // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. - let scale = canvas_scale(doc); let reduced = (scale != 1.0).then(|| { let mut d = doc.clone(); crate::import::scale_document(&mut d, 1.0 / scale, true); @@ -206,6 +268,8 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result Result u8 { /// [`Document::opened_artboard`] names, else the first) and the canvas's size. Every record holds /// all of these entries. The artboards, the active artboard and the canvas size are written in the /// `/Recorded` part of the `/Document` dictionary, followed by an empty `/NotRecorded` part. -fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { +fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64), library: &str) { let active = doc.opened_artboard.and_then(|id| doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0); s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); for (i, ab) in doc.artboards.iter().enumerate() { @@ -471,6 +552,8 @@ fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) s.push_str("%_; (ArtboardArray) ,\n"); let _ = writeln!(s, "%_{active} /Int (CropAreaActive) ,"); let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); + // The variable library, after the canvas size. + s.push_str(library); s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); } diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 7ebece9d3..f25944060 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -241,6 +241,11 @@ fn view(nodes: &[Arc], depth: usize, out: &mut Vec) { } out.push(s); } + // The matrix is written to ten decimals. + NodeKind::SymbolInstance { symbol, xf } => { + let xf: Vec = xf.as_coeffs().iter().map(|v| format!("{:.6}", v + 0.0)).collect(); + out.push(format!("{head} instance {symbol:?} [{}] {} {:?}", xf.join(" "), r3(f64::from(n.opacity)), n.blend)); + } other => out.push(format!("{head} {other:?}")), } } @@ -602,8 +607,8 @@ fn hidden_layers_open_hidden() { assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}"); } -/// A 200 × 100 document of a gradient fill, a compound path and an image, and what the data holds -/// as plain paths: type and a symbol instance. +/// A 200 × 100 document of a gradient fill, a compound path, an image and a symbol instance, and +/// what the data holds as plain paths: type. fn unsupported() -> Document { let mut doc = Document::new(200.0, 100.0); let layer = doc.layers[0].id; @@ -671,9 +676,7 @@ fn paths(nodes: &[Arc], out: &mut Vec) { fn art_the_slice_does_not_cover_is_written_as_plain_paths() { let doc = unsupported(); let out = export_ai_editing(&doc, &opts()).unwrap(); - for w in [writer::TYPE, writer::LIVE] { - assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); - } + assert_eq!(out.warnings, [writer::TYPE]); let back = read_back(&doc); let layer = back.layers[0].children().unwrap(); let kinds: Vec<&str> = layer @@ -683,12 +686,13 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() { NodeKind::Group { .. } => "group", NodeKind::Compound { .. } => "compound", NodeKind::Image(_) => "image", + NodeKind::SymbolInstance { .. } => "instance", _ => "other", }) .collect(); - assert_eq!(kinds, ["path", "compound", "image", "group", "group"]); + assert_eq!(kinds, ["path", "compound", "image", "group", "instance"]); let mut all = vec![]; - paths(&layer[3..], &mut all); + paths(&layer[3..4], &mut all); assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); // The gradient as a gradient, drawing as it did. let bounds = Rect::new(10.0, 10.0, 30.0, 30.0); @@ -802,11 +806,11 @@ fn a_scaled_nine_slice_symbol_instance_keeps_its_corner_size() { assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); } -/// Nested symbols whose instances multiply stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of -/// symbol art in all, with a warning. +/// Nested symbols whose instances multiply, written as their art (on a large canvas), stop at +/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art in all, with a warning. #[test] fn nested_symbol_instances_stop_at_the_symbol_art_limit() { - let mut doc = Document::new(200.0, 100.0); + let mut doc = Document::new(20000.0, 100.0); let layer = doc.layers[0].id; // Empty paths: counted, but written as nothing, so the test stays small; and one filled square. let empty = Arc::new(Node::path(doc.alloc_id(), PathData::new(vec![]), Appearance::default())); @@ -824,7 +828,9 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_LIMIT), "{:?}", out.warnings); // 5,001 objects for the tree and each leaf: 3,198 leaves fit in 16,000,000. let text = String::from_utf8(out.program).unwrap(); - assert_eq!(text.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); + // On the layers; the symbols' definitions come before them, in the setup. + let layers = text.split_once("%%EndSetup").unwrap().1; + assert_eq!(layers.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); } #[test] @@ -900,7 +906,7 @@ fn geometry_effects_in_a_symbol_are_written_as_their_result() { let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 20.0)) }); doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); - let b = back.layers[0].children().unwrap()[0].visual_bounds().unwrap(); + let b = back.bounds_of_with(&[back.layers[0].children().unwrap()[0].id], true, &back.symbol_boxes()).unwrap(); // The 10 pt square offset by 5 pt, at (50, 20). assert!((b.x0 - 45.0).abs() < 0.1 && (b.x1 - 65.0).abs() < 0.1 && (b.y0 - 15.0).abs() < 0.1 && (b.y1 - 35.0).abs() < 0.1, "{b:?}"); } @@ -925,7 +931,8 @@ fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() { doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); let mut found = vec![]; - pictures(&back, &back.layers, "", &mut found); + let art: Vec> = back.symbols.iter().map(|s| s.art.clone()).collect(); + pictures(&back, &art, "", &mut found); assert_eq!(found.len(), 1, "the adjusted image is found"); assert!(!found[0].ends_with("rgba [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]"), "the image is adjusted: {found:?}"); } @@ -1015,6 +1022,26 @@ fn the_active_artboard_follows_the_artboards() { } } +/// The copy's active artboard read on its own, for a document restored from the file beside the +/// copy: `None` for the first, and when the copy names an artboard that document doesn't have or +/// can't be decoded. +#[test] +fn a_restored_document_opens_on_the_artboard_the_copy_names() { + let mut doc = Document::new(200.0, 100.0); + for (id, x0) in [(2, 250.0), (3, 500.0)] { + doc.artboards.push(Artboard { id, rect: Rect::new(x0, 0.0, x0 + 200.0, 100.0), ..doc.artboards[0].clone() }); + } + let mut one_artboard = doc.clone(); + one_artboard.artboards.truncate(1); + for (opened, read) in [(Some(3), Some(3)), (Some(2), Some(2)), (Some(1), None), (None, None), (Some(9), None)] { + doc.opened_artboard = opened; + let data = private_data(&doc, &opts()).unwrap().0.data; + assert_eq!(crate::ai_opened_artboard(&data, &doc), read, "{opened:?}"); + assert_eq!(crate::ai_opened_artboard(&data, &one_artboard), None, "{opened:?}"); + } + assert_eq!(crate::ai_opened_artboard(b"%AI24_ZStandard_Data not zstd", &doc), None); +} + /// A guide is its path followed by `(n) *` (closed) or `(N) *` (open), with no `(N) *` before /// it, and reads back as the same guide. #[test] @@ -1206,6 +1233,7 @@ fn looks(nodes: &[Arc], depth: usize, out: &mut Vec) { NodeKind::Group { clip, .. } => format!("group clip={clip}"), NodeKind::Path { clipping, .. } => format!("path clipping={clipping}"), NodeKind::Compound { .. } => "compound".into(), + NodeKind::SymbolInstance { symbol, .. } => format!("instance {symbol:?}"), _ => "other".into(), }; let mut s = format!( @@ -1376,8 +1404,8 @@ fn transparency_is_written_in_its_forms() { ); } -/// A clipping path filled and stroked keeps its stroke; the reader reads its fill as a path of -/// its own under the clipped art, as the file's PDF page draws it. +/// A clipping path filled and stroked is written with its fill and stroke, and reads back with +/// both on the clipping path. #[test] fn a_painted_clipping_path_is_written_with_its_paint() { let mut doc = Document::new(200.0, 100.0); @@ -1401,8 +1429,7 @@ fn a_painted_clipping_path_is_written_with_its_paint() { assert_eq!( painted, [ - (true, None, Some(paint(&solid(Color::BLACK)))), - (false, Some(paint(&solid(Color::rgb(1.0, 1.0, 0.0)))), None), + (true, Some(paint(&solid(Color::rgb(1.0, 1.0, 0.0)))), Some(paint(&solid(Color::BLACK)))), (false, Some(paint(&solid(Color::rgb(0.0, 0.0, 1.0)))), None) ] ); @@ -2070,8 +2097,7 @@ fn gradients_and_patterns_are_written_in_their_forms() { assert!(art.contains(" p\n") && art.contains(" P\n"), "{art}"); } -/// A clipping path filled with a gradient keeps it: the reader reads it as the clipping path and -/// its fill as a path of its own under the art. +/// A clipping path filled with a gradient keeps it, and reads back with it. #[test] fn a_clipping_path_keeps_its_gradient() { let mut doc = Document::new(200.0, 100.0); @@ -2087,7 +2113,9 @@ fn a_clipping_path_keeps_its_gradient() { assert!(text.contains("h\nW\nBb\n0 (Gradient) "), "{text}"); let back = read_back(&doc); let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") }; - let fill = &children[1]; + assert_eq!(children.len(), 2, "the clipping path and the art"); + let fill = &children[0]; + assert!(matches!(fill.kind, NodeKind::Path { clipping: true, .. }), "{:?}", fill.kind); let bounds = Rect::new(10.0, 10.0, 90.0, 60.0); let want = drawn(&doc, &gradient_paint(sunset(), None, None), bounds); assert_eq!(drawn(&back, fill.appearance.items[0].paint(), bounds), want); @@ -3322,6 +3350,126 @@ fn type_lines(doc: &Document) -> Vec { out } +// ---------- symbols ---------- + +const LEAF: Color = Color::Cmyk { c: 0.6, m: 0.0, y: 0.9, k: 0.1 }; + +fn instance(doc: &mut Document, symbol: &str, xf: Affine) -> Node { + Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +/// A `w` × `h` document whose swatches are one CMYK color (the default swatches' CMYK equivalents +/// change at the fourth decimal when read back, so a second write would differ there). +fn one_swatch_document(w: f64, h: f64) -> Document { + let mut doc = Document::new(w, h); + doc.swatches = vec![Swatch { name: "Leaf".into(), paint: solid(LEAF), global: false, spot: false }].into(); + doc.swatch_groups = vec![].into(); + doc +} + +/// A 300 × 200 document of symbols. Its symbols, in the Symbols panel's order: `Spare`, a path no +/// instance uses; `Pair`, a group of a red square and an instance of `Leaf` at half its size; `Leaf`, +/// a group of a square in `LEAF` and an ellipse, its registration point the top left; `Shade`, a +/// group of a rectangle filled with a gradient, a clipping group and an image, its registration +/// point the bottom right; `Solo`, a group of one square, which no instance uses. Layer `Art` +/// holds, bottom first: `Leaf` moved; `Leaf` turned 30° and scaled 1.5 by 0.5, named `Turned`; a +/// hidden one; a locked one; one at 50% in Multiply; `Pair`; a group holding `Leaf`; a clipping +/// group holding `Leaf`; `Leaf` with an opacity mask whose art holds an instance of `Leaf`; `Shade`; +/// and sublayer `Inner` (60%, Screen), which holds `Pair` mirrored. +fn symbols_document() -> Document { + use vectorcraft_color::gradient::GradientGeom; + let mut doc = Document::new(300.0, 200.0); + doc.swatches = vec![Swatch { name: "Leaf".into(), paint: solid(LEAF), global: false, spot: false }].into(); + doc.swatch_groups = vec![].into(); + doc.layers.clear(); + let art_layer = doc.add_layer(Some("Art")); + // The symbols. + let spare = square(&mut doc, Rect::new(-5.0, -5.0, 5.0, 5.0), Color::gray(0.4)); + let leaf_square = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), LEAF); + let ellipse = + Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(5.0, 10.0, 25.0, 30.0)), Appearance::basic(Paint::None, solid(Color::BLACK), 1.5)); + let leaf = group(&mut doc, vec![leaf_square, ellipse], false); + let red = square(&mut doc, Rect::new(-10.0, -10.0, 10.0, 10.0), Color::rgb(1.0, 0.0, 0.0)); + let small = instance(&mut doc, "Leaf", Affine::translate((15.0, -5.0)) * Affine::scale(0.5)); + let pair = group(&mut doc, vec![red, small], false); + let geom = GradientGeom { start: (0.0, 0.0).into(), end: (40.0, 10.0).into(), aspect: 1.0, focal: None }; + let shaded = painted( + &mut doc, + shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(sunset(), Some(geom), None)))], + ); + let edge = clipping(&mut doc, shapes::ellipse(Rect::new(0.0, 20.0, 20.0, 40.0))); + let inside = square(&mut doc, Rect::new(0.0, 25.0, 40.0, 35.0), Color::rgb(0.0, 0.0, 1.0)); + let clipped = group(&mut doc, vec![edge, inside], true); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 128, 255, 255]).unwrap())); + let picture = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 1), Affine::new([10.0, 0.0, 0.0, 10.0, 25.0, 25.0]), None); + let shade = group(&mut doc, vec![shaded, clipped, picture], false); + let lone = square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::gray(0.2)); + let solo = group(&mut doc, vec![lone], false); + for (name, art) in [("Spare", spare), ("Pair", pair), ("Leaf", leaf), ("Shade", shade), ("Solo", solo)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + doc.set_symbol_registration("Leaf", vectorcraft_doc::SymbolRegistration(0)); + doc.set_symbol_registration("Shade", vectorcraft_doc::SymbolRegistration(8)); + // The instances. + let mut art = vec![instance(&mut doc, "Leaf", Affine::translate((10.0, 10.0)))]; + let turned = Affine::translate((80.0, 20.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale_non_uniform(1.5, 0.5); + art.push(named(instance(&mut doc, "Leaf", turned), "Turned")); + let mut hidden = instance(&mut doc, "Leaf", Affine::translate((130.0, 10.0))); + hidden.visible = false; + art.push(hidden); + let mut locked = instance(&mut doc, "Leaf", Affine::translate((160.0, 10.0))); + locked.locked = true; + art.push(locked); + art.push(see_through(instance(&mut doc, "Leaf", Affine::translate((190.0, 10.0))), 0.5, BlendMode::Multiply)); + art.push(instance(&mut doc, "Pair", Affine::translate((240.0, 30.0)))); + let grouped = instance(&mut doc, "Leaf", Affine::translate((10.0, 60.0))); + art.push(group(&mut doc, vec![grouped], false)); + let window = clipping(&mut doc, shapes::rectangle(Rect::new(50.0, 60.0, 65.0, 75.0))); + let clipped = instance(&mut doc, "Leaf", Affine::translate((50.0, 60.0))); + art.push(group(&mut doc, vec![window, clipped], true)); + let mask_leaf = instance(&mut doc, "Leaf", Affine::translate((90.0, 60.0))); + let mask_art = group(&mut doc, vec![mask_leaf], false); + let mut masked = instance(&mut doc, "Leaf", Affine::translate((90.0, 60.0))); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + art.push(masked); + art.push(instance(&mut doc, "Shade", Affine::translate((130.0, 60.0)))); + for n in art { + doc.insert(Some(art_layer), usize::MAX, n).unwrap(); + } + let inner = doc.add_layer(Some("Inner")); + let inner_node = doc.layers.iter().position(|l| l.id == inner).map(|i| doc.layers.remove(i)).unwrap(); + doc.insert(Some(art_layer), usize::MAX, (*inner_node).clone()).unwrap(); + let mirrored = instance(&mut doc, "Pair", Affine::new([-1.0, 0.0, 0.0, 1.0, 260.0, 120.0])); + doc.insert(Some(inner), usize::MAX, mirrored).unwrap(); + // The sublayer's own transparency, after its last object, an instance. + let sub = doc.node_mut(inner).unwrap(); + (sub.opacity, sub.blend) = (0.6, BlendMode::Screen); + doc +} + +/// Each symbol of `doc` in its order: its name and registration point, then what its art draws, its +/// objects as [`view`] shows them (points, handles, paint, strokes and instances), its images, and +/// its objects' names, transparency and clipping. +fn symbol_looks(doc: &Document) -> Vec { + let mut out = vec![]; + for s in doc.symbols.iter() { + out.push(format!("symbol {} {:?}", s.name, doc.symbol_registration(&s.name))); + let art = std::slice::from_ref(&s.art); + paints_of(doc, art, 1, &mut out); + let mut objects = vec![]; + view(art, 1, &mut objects); + // Images are compared by `pictures`, gradients by `paints_of`. + out.extend(objects.into_iter().filter(|l| !l.contains(" Image(")).map(|l| match l.find(" fill(Gradient(") { + Some(at) => format!("{} fill(gradient)", &l[..at]), + None => l, + })); + pictures(doc, art, &s.name, &mut out); + looks(art, 1, &mut out); + } + out +} + #[test] fn type_comes_back_as_type() { let doc = typed(); @@ -4132,3 +4280,2345 @@ fn type_with_an_opacity_mask_is_written_as_its_outlines() { assert!(!matches!(top.kind, NodeKind::Text(_))); assert!(top.extras().mask.is_some()); } + +#[test] +fn symbols_and_their_instances_are_read_back() { + let doc = symbols_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&lines(&back), &lines(&doc)); + same_lines(&all_looks(&back), &all_looks(&doc)); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + // Each instance draws where it did: the symbols' art and the instances' places agree. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let mut ids = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + ids.push(n.id); + } + }); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + same_lines(&boxes(&back), &boxes(&doc)); +} + +#[test] +fn symbols_write_the_same_bytes_again() { + let doc = symbols_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); + let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&first.program).lines().map(String::from).collect::>()); + assert_eq!(again, first.program); +} + +#[test] +fn symbols_are_written_in_their_forms() { + let p = program_shown(&symbols_document()); + // The definitions in a non-printing block after the patterns, in the order the art first uses + // them (each after those its art uses), the others after them; and the list after the palette + // in the panel's order. + let defs = ["(Leaf)", "(Pair)", "(Shade)", "(Spare)", "(Solo)"].map(|n| p.find(&format!("%AI14_BeginSymbol\n{n}\n0 A\n0 Xw\n0 Ae\n")).unwrap()); + assert!(defs.windows(2).all(|w| w[0] < w[1]), "{p}"); + assert!(p.contains("%AI5_Begin_NonPrinting\nNp\n%AI14_BeginSymbol\n(Leaf)\n"), "{p}"); + let list = + "%AI5_Begin_NonPrinting\nNp\n%AI24_BeginSymbolList\n(Spare)\n(Pair)\n(Leaf)\n(Shade)\n(Solo)\n%AI24_EndSymbolList\n%AI5_End_NonPrinting--\n"; + let at = p.find(list).unwrap(); + assert!(p.find("%AI5_EndPalette").unwrap() < at && at < p.find("%AI9_BeginDocumentData").unwrap(), "{p}"); + // A group's registration point in its dictionary, before its state block; a path is written in + // a group of its own. + assert!(p.contains("U\n%_/ArtDictionary :\n%_1 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"), "{p}"); + assert!(p.contains("%_/ArtDictionary :\n%_9 /Int (AI13PatternRegistrationType) ,\n"), "{p}"); + assert!(p.contains("%AI14_BeginSymbol\n(Spare)\n0 A\n0 Xw\n0 Ae\nu\n"), "{p}"); + // A plain group of one object in a group of its own. + assert!(p.contains("%AI14_BeginSymbol\n(Solo)\n0 A\n0 Xw\n0 Ae\nu\nu\n"), "{p}"); + assert!(p.contains("f\nU\n%_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"), "{p}"); + // An instance on the page: its matrix takes the art to art space (y up from the artboard's top + // left), then its state block. + assert!(p.contains("/SymbolInstance :\n(Leaf) /SymbolRef ,\n1 0 0 -1 10 -10 /RTransformMatrix\n;\n9 () XW\n"), "{p}"); + // Its name between the two, and its transparency as a group's. + assert!(p.contains("/RTransformMatrix\n;\n%_/ArtDictionary :\n%_/XMLUID : (Turned) ; (AI10_ArtUID) ,\n%_;\n%_\n9 () XW\n"), "{p}"); + assert!(p.contains("1 0 0 -1 190 -10 /RTransformMatrix\n;\n1 0.5 0 2 0 Xy\n0 0 Xd\n6 () XW\n"), "{p}"); + // In a symbol's art, about the canvas point of art space's origin (the template box's center + // is the canvas's). + let t: Vec = p.lines().find_map(|l| l.strip_prefix("%AI3_TemplateBox: ")).unwrap().split(' ').map(|v| v.parse().unwrap()).collect(); + let (ox, oy) = (f64::from(CANVAS_SIZE) / 2.0 - t[0], f64::from(CANVAS_SIZE) / 2.0 + t[1]); + let nested = format!("(Leaf) /SymbolRef ,\n0.5 0 0 -0.5 {} {} /RTransformMatrix\n", ps::num(15.0 - ox), ps::num(oy + 5.0)); + assert!(p.contains(&nested), "{nested}\n{p}"); +} + +/// Instances that the data can't hold as instances are written as their art, with a warning: a +/// stained one, one of a symbol with 9-slice guides, one scaled almost flat, every one on the +/// layers of a large canvas (whose symbols are still defined), one in a pattern's tile, one with a +/// dynamic symbol's appearance changes and one in art cut to a clipping object that isn't a path. +/// A symbol whose art holds an instance of itself is defined once, that instance as art. +#[test] +fn instances_the_data_cant_hold_are_written_as_their_art() { + let leaf = |doc: &mut Document| { + let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + }; + let kinds = |d: &Document| -> Vec<&'static str> { + d.layers[0].children().unwrap().iter().map(|n| if matches!(n.kind, NodeKind::SymbolInstance { .. }) { "instance" } else { "art" }).collect() + }; + // Stained. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let mut stained = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + stained.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + let plain = instance(&mut doc, "Leaf", Affine::translate((80.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, stained).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + assert_eq!(kinds(&read_back(&doc)), ["art", "instance"]); + // 9-slice guides. + doc.set_symbol_nine_slice("Leaf", Some(vectorcraft_doc::SymbolNineSlice::default())); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_ART, writer::SYMBOL_OPTIONS]); + let back = read_back(&doc); + assert_eq!((kinds(&back), back.symbols.len()), (vec!["art", "art"], 1)); + // Scaled so flat that its matrix to ten decimals has no area. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let flat = instance(&mut doc, "Leaf", Affine::new([1e-11, 0.0, 0.0, 1.0, 50.0, 10.0])); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, flat).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + assert_eq!(kinds(&read_back(&doc)), ["art"]); + // A large canvas. + let mut doc = Document::new(20000.0, 100.0); + leaf(&mut doc); + let one = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + // The symbol is still defined, at its own size: the canvas's scale doesn't touch its art. + let back = read_back(&doc); + assert_eq!(kinds(&back), ["art"]); + assert_eq!(symbol_looks(&back), symbol_looks(&doc)); + // A pattern a symbol's art and the layers both paint with: the layers' fill keeps it, the + // symbol's art has its tiles. + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = || Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let patch = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 16.0, 16.0)), fill()); + doc.symbols.push(Symbol { dynamic: false, name: "Patch".into(), art: Arc::new(patch) }); + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 132.0, 42.0)), fill()); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_ART, writer::PATTERNS]); + let back = read_back(&doc); + let on_layer = back.layers[0].children().unwrap()[1].appearance.items[0].paint().clone(); + assert!(matches!(on_layer, Paint::Pattern { .. }), "{on_layer:?}"); + let patch = &back.symbols.iter().find(|s| s.name == "Patch").unwrap().art; + assert!(matches!(patch.kind, NodeKind::Group { .. }), "the symbol's art is its tiles: {:?}", patch.kind); + // A pattern's tile: the tile holds the symbol's art. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let tiled = instance(&mut doc, "Leaf", Affine::IDENTITY); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Tiles", vec![Arc::new(tiled)])); + let fill = Appearance::basic(Paint::Pattern { pattern: "Tiles".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), fill); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + let tile = &back.patterns.iter().find(|p| p.name == "Tiles").unwrap().art; + assert_eq!(tile.len(), 1); + assert_eq!(instances(&tile[0]), 0, "{:?}", tile[0].kind); + let NodeKind::Group { children, .. } = &tile[0].kind else { panic!("{:?}", tile[0].kind) }; + assert_eq!(paints_of_nodes(&back, children), paints_of_nodes(&back, &[Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF))])); + // A symbol holding an instance of itself: defined once, that instance as its art. + let mut doc = Document::new(200.0, 100.0); + let looped_art = { + let me = instance(&mut doc, "Loop", Affine::translate((20.0, 0.0))); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), LEAF); + group(&mut doc, vec![dot, me], false) + }; + doc.symbols.push(Symbol { dynamic: false, name: "Loop".into(), art: Arc::new(looped_art) }); + let looped = instance(&mut doc, "Loop", Affine::translate((100.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, looped).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let p = program(&doc); + assert_eq!(p.matches("%AI14_BeginSymbol\n(Loop)\n").count(), 1, "{p}"); + assert_eq!(kinds(&read_back(&doc)), ["instance"]); + // Appearance changes of a dynamic symbol's instance. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + doc.symbols[0].dynamic = true; + let mut changed = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + changed.extras_mut().symbol_overrides = + vec![vectorcraft_doc::SymbolOverride { fill: Some(solid(Color::rgb(0.0, 1.0, 0.0))), ..Default::default() }]; + let plain = instance(&mut doc, "Leaf", Affine::translate((80.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, changed).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART, writer::SYMBOL_OPTIONS]); + let back = read_back(&doc); + assert_eq!(kinds(&back), ["art", "instance"]); + let green = back.layers[0].children().unwrap()[0].clone(); + let drawn = paints_of_nodes(&back, &[green]); + assert!(drawn.iter().any(|l| l.contains("rgb 0.000 1.000 0.000")), "the change is drawn: {drawn:?}"); + // Art cut to a clipping object that isn't a path. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 255, 255])))); + let frame = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 2), Affine::new([20.0, 0.0, 0.0, 20.0, 40.0, 0.0]), None); + let cut = instance(&mut doc, "Leaf", Affine::translate((45.0, 5.0))); + let clipped = group(&mut doc, vec![frame, cut], true); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, clipped).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.layers.iter().map(|l| instances(l)).sum::(), 0); + assert_eq!(back.symbols.len(), 1); +} + +/// Symbols whose art paints with a pattern, directly, through a nested symbol, in an opacity mask +/// and through an inline graphic: their definitions hold the pattern fills as tiles cut to the shape (the reader keeps no +/// symbol whose art paints with a pattern), and their instances are written as their art, whose +/// pattern fills keep the document's placement as the canvas draws them, each with a warning. An +/// instance of a symbol without pattern paint stays an instance. +#[test] +fn symbols_with_pattern_fills_are_defined_with_tiles_and_placed_as_art() { + let mut doc = Document::new(200.0, 100.0); + // Tiles that cover the shape, so its tiles cut to it draw its box. + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = || Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let patch = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 16.0, 16.0)), fill()); + doc.symbols.push(Symbol { dynamic: false, name: "Patch".into(), art: Arc::new(patch) }); + let inner = instance(&mut doc, "Patch", Affine::translate((20.0, 0.0))); + let edge = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + let outer = group(&mut doc, vec![edge, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) }); + let mask_art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 12.0)), fill()); + let mut masked = square(&mut doc, Rect::new(0.0, 0.0, 12.0, 12.0), LEAF); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + doc.symbols.push(Symbol { dynamic: false, name: "Masked".into(), art: Arc::new(masked) }); + let plain = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Plain".into(), art: Arc::new(plain) }); + // Type holding `Patch` as an inline graphic. + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Patch"), style)); + let typed = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + let layer = doc.layers[0].id; + // Two instances of `Patch` a part of a tile apart. + for (name, x) in [("Patch", 10.0), ("Patch", 33.0), ("Outer", 60.0), ("Masked", 120.0), ("Plain", 140.0), ("Typed", 10.0)] { + let one = instance(&mut doc, name, Affine::translate((x, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(150.0, 10.0, 182.0, 42.0)), fill()); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART) && out.warnings.iter().any(|w| w == writer::PATTERNS), "{:?}", out.warnings); + let back = read_back(&doc); + let names: Vec<&str> = back.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Patch", "Outer", "Masked", "Plain", "Typed"]); + let patch = &back.symbols[0].art; + assert!(matches!(patch.kind, NodeKind::Group { .. }), "the symbol's art is its tiles: {:?}", patch.kind); + assert_eq!(instances(&back.symbols[1].art), 0, "the nested instance is its art"); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, false, true, false, false]); + // The pattern fills of both `Patch` instances and of the layers' path keep one placement. + let placements = |n: &Node| { + let mut out = vec![]; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + for it in m.appearance.items.iter() { + if let Paint::Pattern { xf, .. } = it.paint() { + out.push(xf.as_coeffs().map(r3)); + } + } + }); + out + }; + let layer_fill = placements(&kids[6]); + assert_eq!(layer_fill.len(), 1); + assert_eq!(placements(&kids[0]), layer_fill); + assert_eq!(placements(&kids[1]), layer_fill); + // Each instance draws where it did. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let ids: Vec<_> = d.layers[0].children().unwrap().iter().take(5).map(|n| n.id).collect(); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + same_lines(&boxes(&back), &boxes(&doc)); +} + +/// A symbol whose art paints with a pattern only through a brushed stroke, whose brush's art is +/// filled with a pattern: the definition holds the tiles, and its instance is written as its art. +#[test] +fn symbols_painting_patterns_through_brush_art_are_placed_as_art() { + let mut doc = Document::new(200.0, 100.0); + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 8.0)), fill); + let brush = vectorcraft_brush::Brush { + name: "Dotted".into(), + kind: vectorcraft_brush::BrushKind::Art(vectorcraft_brush::ArtBrush { + art, + colorization: vectorcraft_brush::Colorization::None, + ..Default::default() + }), + }; + vectorcraft_brush::store(&mut doc, &[brush]); + let mut line = + Node::path(doc.alloc_id(), shapes::line((0.0, 0.0).into(), (60.0, 0.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 4.0)); + line.appearance.stroke_mut().unwrap().brush = Some("Dotted".into()); + doc.symbols.push(Symbol { dynamic: false, name: "Brushed".into(), art: Arc::new(line) }); + let layer = doc.layers[0].id; + let one = instance(&mut doc, "Brushed", Affine::translate((10.0, 40.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Brushed"]); + assert_eq!(instances(&back.layers[0]), 0, "the instance is its art"); +} + +/// A symbol whose stroke is written as its outline (an outside stroke): a scaled instance, a scaled +/// instance of a symbol holding it and a turned and mirrored instance are written as their art, +/// which keeps the stroke the canvas draws; a moved instance stays an instance. +#[test] +fn scaled_instances_of_symbols_with_outlined_strokes_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let mut look = Appearance::basic(Paint::None, solid(Color::BLACK), 4.0); + look.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Outside; + let framed = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look); + doc.symbols.push(Symbol { dynamic: false, name: "Framed".into(), art: Arc::new(framed) }); + let inner = instance(&mut doc, "Framed", Affine::IDENTITY); + let edge = square(&mut doc, Rect::new(20.0, 0.0, 30.0, 10.0), LEAF); + let outer = group(&mut doc, vec![inner, edge], false); + doc.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) }); + let layer = doc.layers[0].id; + let turned = Affine::translate((150.0, 20.0)) * Affine::rotate(0.5) * Affine::new([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + for (name, xf) in [ + ("Framed", Affine::translate((40.0, 40.0)) * Affine::scale(2.0)), + ("Outer", Affine::translate((80.0, 10.0)) * Affine::scale(2.0)), + ("Framed", turned), + ("Framed", Affine::translate((170.0, 60.0))), + ] { + let one = instance(&mut doc, name, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Framed", "Outer"]); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, true]); + // Each draws where it did, with the stroke the canvas draws: 4 pt wide whatever the scale. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let ids: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + let (got, want) = (boxes(&back), boxes(&doc)); + same_lines(&[got[0].clone(), got[2].clone(), got[3].clone()], &[want[0].clone(), want[2].clone(), want[3].clone()]); + // `Outer` at twice its size from (80, 10): the frame's square is 20 pt from 80 with its 4 pt + // stroke outside, and the plain square is 40 to 60 pt in from it. + assert_eq!(got[1], format!("{:?}", Some([76.0, 6.0, 140.0, 34.0].map(r3)))); +} + +/// Scaled instances of a symbol holding a rounded rectangle with live corners, of one clipped by +/// it, of one holding type, of one holding a blend and of one holding an instance written as its +/// art (of a symbol with 9-slice guides), and instances that scale their symbol more than 100 +/// times (tiny, thin and stroked tiny objects, and a 10 pt square), are written as their art; +/// instances that scale a 10 pt square a thousandth, scale an ellipse unevenly or only move stay +/// instances. +#[test] +fn scaled_instances_of_symbols_drawn_only_at_their_own_size_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let live = vectorcraft_doc::LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [10.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; + let mut rounded = Node::new( + doc.alloc_id(), + NodeKind::Path { path: live.to_path(), rule: FillRule::NonZero, live: Some(Box::new(live)), clipping: false, guide: false }, + ); + rounded.appearance = Appearance::basic(solid(LEAF), Paint::None, 0.0).into(); + // The same rounded rectangle clipping a square. + let mut frame = rounded.clone(); + frame.id = doc.alloc_id(); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), LEAF); + let clipped = group(&mut doc, vec![frame, inside], true); + doc.symbols.push(Symbol { dynamic: false, name: "Rounded".into(), art: Arc::new(rounded) }); + doc.symbols.push(Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(clipped) }); + // A tiny square with a 1 pt stroke. + let mut dot = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 0.00001), LEAF); + dot.appearance = Appearance::basic(solid(LEAF), solid(Color::BLACK), 1.0).into(); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(dot) }); + // A blend of two squares 20 pt apart with a step every 10 pt. + let keys = + vec![Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 2.0, 2.0), LEAF)), Arc::new(square(&mut doc, Rect::new(20.0, 0.0, 22.0, 2.0), LEAF))]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() }; + let blend = Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }); + doc.symbols.push(Symbol { dynamic: false, name: "Steps".into(), art: Arc::new(blend) }); + // A symbol holding an instance of one with 9-slice guides. + let frame = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), LEAF)); + let corner = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK)); + let sliced = Node::group(doc.alloc_id(), vec![frame, corner]); + doc.symbols.push(Symbol { dynamic: false, name: "Sliced".into(), art: Arc::new(sliced) }); + doc.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let inner = instance(&mut doc, "Sliced", Affine::IDENTITY); + let holder = group(&mut doc, vec![inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Holder".into(), art: Arc::new(holder) }); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 10.0), "Hi", style(10.0, Color::BLACK)); + let typed = text_node(&mut doc, t); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + let tiny = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 0.00001), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Tiny".into(), art: Arc::new(tiny) }); + let thin = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 1.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Thin".into(), art: Arc::new(thin) }); + let plain = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Plain".into(), art: Arc::new(plain) }); + let oval = vectorcraft_doc::LiveShape::Ellipse { w: 10.0, h: 6.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; + let mut ellipse = Node::new( + doc.alloc_id(), + NodeKind::Path { path: oval.to_path(), rule: FillRule::NonZero, live: Some(Box::new(oval)), clipping: false, guide: false }, + ); + ellipse.appearance = Appearance::basic(solid(LEAF), Paint::None, 0.0).into(); + doc.symbols.push(Symbol { dynamic: false, name: "Oval".into(), art: Arc::new(ellipse) }); + let layer = doc.layers[0].id; + for (name, xf) in [ + ("Rounded", Affine::new([0.3, 0.0, 0.0, 0.1, 10.0, 10.0])), + ("Typed", Affine::translate((50.0, 50.0)) * Affine::scale(3.0)), + ("Tiny", Affine::translate((100.0, 50.0)) * Affine::scale(1e6)), + ("Plain", Affine::translate((150.0, 50.0)) * Affine::scale(1e-3)), + ("Plain", Affine::translate((150.0, 60.0)) * Affine::scale(1e3)), + ("Oval", Affine::translate((170.0, 20.0)) * Affine::new([3.0, 0.0, 0.0, 1.0, 0.0, 0.0])), + ("Thin", Affine::translate((100.0, 70.0)) * Affine::new([1e6, 0.0, 0.0, 10.0, 0.0, 0.0])), + ("Clipped", Affine::new([0.3, 0.0, 0.0, 0.1, 10.0, 30.0])), + ("Dot", Affine::translate((120.0, 70.0)) * Affine::scale(1e6)), + ("Steps", Affine::translate((10.0, 80.0)) * Affine::scale(5.0)), + ("Holder", Affine::scale(2.0)), + ("Holder", Affine::translate((5.0, 5.0))), + ] { + let one = instance(&mut doc, name, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, true, false, true, false, false, false, false, false, true]); + let b = kids[6].visual_bounds().map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)); + assert_eq!(b, Some([100.0, 70.0, 110.0, 80.0].map(r3))); + // The tiny square comes back 10 pt across, where the instance put it. + let b = kids[2].visual_bounds().map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)); + assert_eq!(b, Some([100.0, 50.0, 110.0, 60.0].map(r3))); +} + +/// A symbol whose art is a hidden path with two fills: the canvas draws a symbol's art even when +/// its root is hidden, so the definition's root is written shown and the instance still draws. +#[test] +fn a_symbol_whose_root_is_hidden_still_draws() { + let mut doc = Document::new(200.0, 100.0); + let mut look = Appearance::basic(solid(LEAF), Paint::None, 0.0); + look.items.push(AppearanceItem::Fill(FillLayer::new(solid(Color::BLACK)))); + let mut art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look); + art.visible = false; + doc.symbols.push(Symbol { dynamic: false, name: "Hidden".into(), art: Arc::new(art) }); + let layer = doc.layers[0].id; + let one = instance(&mut doc, "Hidden", Affine::translate((20.0, 20.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap(); + assert!(matches!(kids[0].kind, NodeKind::SymbolInstance { .. })); + let mut shown = 0; + vectorcraft_doc::swatches::walk_with_masks(&back.symbols[0].art, &mut |m| { + shown += usize::from(m.visible && matches!(m.kind, NodeKind::Path { .. })) + }); + assert!(back.symbols[0].art.visible && shown > 0, "{:?}", back.symbols[0].art); +} + +/// The number of symbol instances in `n` and its subtree. +fn instances(n: &Node) -> usize { + let mut count = 0; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| count += usize::from(matches!(m.kind, NodeKind::SymbolInstance { .. }))); + count +} + +/// What `nodes` draw ([`paints_of`]). +fn paints_of_nodes(doc: &Document, nodes: &[Arc]) -> Vec { + let mut out = vec![]; + paints_of(doc, nodes, 1, &mut out); + out +} + +/// An instance with isolation, knockout or knockout shape of its own is written as its art, which +/// keeps them as a group's; a plain one beside it stays an instance. +#[test] +fn instances_with_a_groups_transparency_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let art = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + let mut shape = instance(&mut doc, "Leaf", Affine::translate((10.0, 10.0))); + shape.knockout_shape = true; + let mut isolated = instance(&mut doc, "Leaf", Affine::translate((30.0, 10.0))); + isolated.isolate = true; + let mut knocks = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + knocks.knockout = Knockout::On; + let plain = instance(&mut doc, "Leaf", Affine::translate((70.0, 10.0))); + let mut outer = group(&mut doc, vec![shape, isolated, knocks, plain], false); + outer.knockout = Knockout::On; + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, outer).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap()[0].children().unwrap().clone(); + let states: Vec<_> = kids.iter().map(|n| (matches!(n.kind, NodeKind::SymbolInstance { .. }), n.knockout_shape, n.isolate, n.knockout)).collect(); + assert_eq!( + states, + [ + (false, true, false, Knockout::Neutral), + (false, false, true, Knockout::Neutral), + (false, false, false, Knockout::On), + (true, false, false, Knockout::Neutral) + ] + ); + // Each group holds the symbol's art where the instance placed it. + for (i, k) in kids[..3].iter().enumerate() { + let b = k.geometric_bounds().unwrap(); + assert_eq!([b.x0, b.y0, b.x1, b.y1].map(r3), [10.0 + 20.0 * i as f64, 10.0, 20.0 + 20.0 * i as f64, 20.0].map(r3)); + } +} + +/// A symbol's definition is the same wherever its first instance lies: one first met deep in +/// opacity masks keeps its own masks and instances. +#[test] +fn a_symbol_first_used_deep_in_masks_is_defined_whole() { + let mut doc = Document::new(200.0, 100.0); + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + // `Deep`: a square with an opacity mask, and an instance of `Leaf`. + let mask_art = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::WHITE); + let mut veiled = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), Color::BLACK); + veiled.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + let inner = instance(&mut doc, "Leaf", Affine::translate((5.0, 5.0))); + let deep = group(&mut doc, vec![veiled, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Deep".into(), art: Arc::new(deep) }); + // Its only instance, in the art of an opacity mask seven masks deep. + let mut art = instance(&mut doc, "Deep", Affine::translate((50.0, 20.0))); + for _ in 0..7 { + let mut holder = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::gray(0.5)); + holder.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true })); + art = holder; + } + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, art).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::DEEP_MASKS || w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + let deep = &back.symbols.iter().find(|s| s.name == "Deep").unwrap().art; + assert_eq!(instances(deep), 1); + let mut masks = 0; + deep.walk(&mut |n| masks += usize::from(n.extras().mask.is_some())); + assert_eq!(masks, 1); +} + +/// Symbols past the 4,096 the reader keeps are left out, their instances written as their art. +#[test] +fn symbols_past_the_readers_count_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + for i in 0..=crate::import::MAX_SYMBOLS { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(art) }); + } + // An instance of each, in the panel's order: the last one's symbol is past the count. + let layer = doc.layers[0].id; + for i in 0..=crate::import::MAX_SYMBOLS { + let one = instance(&mut doc, &format!("S{i}"), Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_COUNT, writer::SYMBOL_ART]); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), crate::import::MAX_SYMBOLS); + assert!(back.symbols.iter().all(|s| s.name != format!("S{}", crate::import::MAX_SYMBOLS))); + let live = back.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect::>(); + assert_eq!(live.len(), crate::import::MAX_SYMBOLS + 1); + assert!(live[..crate::import::MAX_SYMBOLS].iter().all(|l| *l) && !live[crate::import::MAX_SYMBOLS]); +} + +/// With one place left under the reader's count, a symbol whose type holds an inline graphic with +/// an instance of another symbol (found only as its art is written) takes the last place, and that +/// instance is written as its art: no more definitions are written than the reader keeps. +#[test] +fn a_symbol_found_while_its_holder_is_written_keeps_within_the_readers_count() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for i in 0..crate::import::MAX_SYMBOLS - 1 { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(art) }); + let one = instance(&mut doc, &format!("S{i}"), Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Inner"), style)); + let typed = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + // The inline graphic's art holds an instance of a symbol of its own. + let deeper = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Deeper".into(), art: Arc::new(deeper) }); + let nested = instance(&mut doc, "Deeper", Affine::translate((1.0, 1.0))); + let edge = square(&mut doc, Rect::new(0.0, 0.0, 2.0, 2.0), LEAF); + let inner = group(&mut doc, vec![edge, nested], false); + doc.symbols.push(Symbol { dynamic: false, name: "Inner".into(), art: Arc::new(inner) }); + let one = instance(&mut doc, "Typed", Affine::translate((50.0, 50.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let program = String::from_utf8_lossy(&out.program); + assert_eq!(program.matches("%AI14_BeginSymbol").count(), crate::import::MAX_SYMBOLS); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), crate::import::MAX_SYMBOLS); + assert!(back.symbols.iter().any(|s| s.name == "Typed")); + assert!(matches!(back.layers[0].children().unwrap().last().unwrap().kind, NodeKind::SymbolInstance { .. })); +} + +/// A linked image in a symbol's art is embedded; a dynamic symbol and a symbol's export type are +/// reported left out. +#[test] +fn symbol_options_and_linked_images_in_symbols_are_reported() { + let mut doc = Document::new(200.0, 100.0); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 255, 255])))); + let link = vectorcraft_doc::LinkInfo { path: "/tmp/blue.png".into(), ..Default::default() }; + let picture = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 2), Affine::scale(5.0), Some(link)); + doc.symbols.push(Symbol { dynamic: true, name: "Picture".into(), art: Arc::new(picture) }); + let one = instance(&mut doc, "Picture", Affine::translate((50.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_OPTIONS, writer::LINKS_EMBEDDED]); + let p = program_shown(&doc); + assert!(p.contains("%AI5_BeginRaster") && !p.contains("%AI5_BeginPlace"), "{p}"); + let back = read_back(&doc); + let NodeKind::Image(im) = &back.symbols[0].art.kind else { panic!("the symbol's image") }; + assert!(im.link.is_none()); + doc.symbols[0].dynamic = false; + doc.set_symbol_export_type("Picture", vectorcraft_doc::SymbolExportType::Graphic); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_OPTIONS, writer::LINKS_EMBEDDED]); +} + +/// Each of the nine registration points is written 1 to 9 by its place on the grid, row by row +/// from the top left, and read back. +#[test] +fn every_registration_point_is_written_by_its_place() { + for (point, written, read) in (0..9).map(|r| (r, r + 1, r)) { + let mut doc = Document::new(200.0, 100.0); + let art = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) }); + doc.set_symbol_registration("Mark", vectorcraft_doc::SymbolRegistration(point)); + let p = program_shown(&doc); + assert!(p.contains(&format!("%_{written} /Int (AI13PatternRegistrationType) ,\n")), "{point}: {p}"); + assert_eq!(read_back(&doc).symbol_registration("Mark"), vectorcraft_doc::SymbolRegistration(read), "{point}"); + } +} + +/// Symbols whose art is a group with a state of its own (a name, transparency, an opacity mask, a +/// clipping group) come back with it; the registration point shares the group's dictionary. +#[test] +fn symbols_whose_art_is_a_group_with_a_state_come_back() { + let mut doc = one_swatch_document(200.0, 100.0); + let pair = |doc: &mut Document| { + let a = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + let b = square(doc, Rect::new(5.0, 5.0, 15.0, 15.0), Color::rgb(1.0, 0.0, 0.0)); + vec![a, b] + }; + let kids = pair(&mut doc); + let labeled = named(group(&mut doc, kids, false), "Label"); + let kids = pair(&mut doc); + let faded = see_through(group(&mut doc, kids, false), 0.5, BlendMode::Multiply); + let kids = pair(&mut doc); + let mut masked = group(&mut doc, kids, false); + let mask_art = square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::WHITE); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: true, disabled: false, linked: true })); + let edge = clipping(&mut doc, shapes::ellipse(Rect::new(0.0, 0.0, 12.0, 12.0))); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), Color::rgb(0.0, 0.0, 1.0)); + let window = group(&mut doc, vec![edge, inside], true); + let lone = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::gray(0.3)); + let lone_named = named(group(&mut doc, vec![lone], false), "Lone"); + for (name, art) in [("Labeled", labeled), ("Faded", faded), ("Masked", masked), ("Window", window), ("Lone", lone_named)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + let one = instance(&mut doc, name, Affine::translate((20.0, 20.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + doc.set_symbol_registration("Labeled", vectorcraft_doc::SymbolRegistration(2)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + same_lines(&all_looks(&back), &all_looks(&doc)); + assert_eq!(export_ai_editing(&back, &opts()).unwrap().program, out.program); + let p = program_shown(&doc); + // The name and the registration point in one dictionary, the group not wrapped again. + assert!(p.contains("%AI14_BeginSymbol\n(Labeled)\n0 A\n0 Xw\n0 Ae\nu\n0.6 0 0.9 0.1 k\n"), "{p}"); + assert!(p.contains("%_/ArtDictionary :\n%_3 /Int (AI13PatternRegistrationType) ,\n%_/XMLUID : (Label) ; (AI10_ArtUID) ,\n%_;\n"), "{p}"); +} + +/// The names of symbols in every form a string takes: parentheses, a backslash, a slash, text +/// outside ASCII, an empty name (which the reader names `Symbol`); two symbols of one name keep the +/// first; an instance of a symbol the document doesn't have is an empty group, with a warning that +/// says so. +#[test] +fn symbol_names_and_missing_symbols() { + let mut doc = Document::new(200.0, 100.0); + let names = ["Paren (a)", "Back\\slash", "Path/To", "Hoja ñ 葉", "", "Twice"]; + for (i, name) in names.iter().chain(["Twice"].iter()).enumerate() { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0 + i as f64, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: (*name).into(), art: Arc::new(art) }); + } + let layer = doc.layers[0].id; + for name in names { + let one = instance(&mut doc, name, Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + let names = names.map(|n| if n.is_empty() { "Symbol" } else { n }); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), names); + let refs: Vec = back.layers[0] + .children() + .unwrap() + .iter() + .map(|n| match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbol.clone(), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!(refs, names); + // The first symbol of a name is the one defined. + let twice = &back.symbols.iter().find(|s| s.name == "Twice").unwrap().art; + assert_eq!(paints_of_nodes(&back, std::slice::from_ref(twice)), paints_of_nodes(&doc, std::slice::from_ref(&doc.symbols[5].art))); + // A missing symbol. + let lost = instance(&mut doc, "Gone", Affine::translate((40.0, 10.0))); + doc.insert(Some(layer), usize::MAX, lost).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::MISSING_SYMBOL]); + let back = read_back(&doc); + let last = back.layers[0].children().unwrap().last().unwrap().clone(); + assert!(matches!(&last.kind, NodeKind::Group { children, clip: false } if children.is_empty()), "{:?}", last.kind); +} + +/// On a large canvas, a symbol's art holding a gradient and a nested instance is defined at its own +/// size and comes back as it was. +#[test] +fn a_symbol_with_a_gradient_and_an_instance_on_a_large_canvas_comes_back() { + use vectorcraft_color::gradient::GradientGeom; + let mut doc = Document::new(20000.0, 100.0); + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + let geom = GradientGeom { start: (0.0, 0.0).into(), end: (40.0, 10.0).into(), aspect: 1.0, focal: None }; + let shaded = painted( + &mut doc, + shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(sunset(), Some(geom), None)))], + ); + let nested = instance(&mut doc, "Leaf", Affine::translate((25.0, 5.0)) * Affine::rotate(0.4)); + let both = group(&mut doc, vec![shaded, nested], false); + doc.symbols.push(Symbol { dynamic: false, name: "Both".into(), art: Arc::new(both) }); + let one = instance(&mut doc, "Both", Affine::translate((100.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); +} + +/// Instance matrices of every kind (turned, sheared, mirrored, scaled near the smallest area the +/// data keeps), nested in one another, are read back as written, and written again as the same +/// bytes. The matrices come from a fixed seed. +#[test] +fn generated_instance_matrices_come_back() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + for round in 0..40 { + let mut doc = one_swatch_document(300.0, 300.0); + let base = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(base) }); + let matrix = |next: &mut dyn FnMut() -> f64| { + // Four nested matrices together scale by at most 64, within the 100 an instance may. + let scale = 10f64.powf(next() * 3.0 - 3.0); + Affine::new([ + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + next() * 300.0, + next() * 300.0, + ]) + }; + // A chain of symbols, each holding an instance of the one before. + for level in 1..4 { + let xf = matrix(&mut next); + let inner = instance(&mut doc, &format!("S{}", level - 1), xf); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 3.0, 3.0), Color::gray(0.4)); + let art = group(&mut doc, vec![dot, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{level}"), art: Arc::new(art) }); + } + let layer = doc.layers[0].id; + for _ in 0..6 { + let pick = format!("S{}", (next() * 4.0) as usize % 4); + let xf = matrix(&mut next); + let one = instance(&mut doc, &pick, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + let back = read_back(&doc); + // Each instance written as one comes back with its matrix to ten decimals. + let written = |d: &Document| { + let mut all = vec![]; + for n in d.layers[0].children().unwrap().iter().chain(d.symbols.iter().map(|s| &s.art)) { + n.walk(&mut |m| { + if let NodeKind::SymbolInstance { symbol, xf } = &m.kind { + all.push((symbol.clone(), *xf)); + } + }); + } + all + }; + assert!(out.warnings.is_empty(), "round {round}: {:?}", out.warnings); + let (got, want) = (written(&back), written(&doc)); + assert_eq!(got.len(), want.len(), "round {round}"); + for (g, w) in got.iter().zip(&want) { + assert_eq!(g.0, w.0, "round {round}"); + for (a, b) in g.1.as_coeffs().iter().zip(w.1.as_coeffs()) { + assert!((a - b).abs() <= 1e-9 * b.abs().max(1.0), "round {round}: {:?} {:?}", g.1, w.1); + } + } + let again = export_ai_editing(&back, &opts()).unwrap().program; + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&out.program).lines().map(String::from).collect::>()); + assert_eq!(again, out.program, "round {round}"); + } + // About the smallest area the data keeps: an instance whose matrix keeps it stays one, one + // whose matrix (to ten decimals) loses it is written as its art. + for (xf, live) in [ + (Affine::new([1e-6, 0.0, 0.0, 1.1e-6, 50.0, 10.0]), true), + (Affine::new([1e-6, 0.0, 0.0, 0.9e-6, 50.0, 10.0]), false), + (Affine::new([1.0, 0.4, 2.5, 1.0000000001, 50.0, 10.0]), true), + (Affine::new([4e-11, 0.0, 0.0, 1e6, 50.0, 10.0]), false), + ] { + let mut doc = Document::new(200.0, 100.0); + let base = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(base) }); + let one = instance(&mut doc, "S0", xf); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let back = read_back(&doc); + assert_eq!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::SymbolInstance { .. }), live, "{xf:?}"); + } +} + +/// Type in a symbol's art is written as its outlines (the reader reads no type there), so the +/// symbol and its instance come back; type on the layers stays type. +#[test] +fn type_in_a_symbols_art_is_written_as_its_outlines() { + let mut doc = Document::new(200.0, 100.0); + let label = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "Tag", style(18.0, Color::BLACK)); + let label = text_node(&mut doc, label); + doc.symbols.push(Symbol { dynamic: false, name: "Tag".into(), art: Arc::new(label) }); + let one = instance(&mut doc, "Tag", Affine::translate((20.0, 20.0))); + let plain = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Plain", style(18.0, Color::BLACK)); + let plain = text_node(&mut doc, plain); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::TYPE]); + assert_eq!(program(&doc).matches("/AI11Text :").count(), 1); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), 1); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::SymbolInstance { .. })); +} + +// ---------- art styles ---------- + +/// Effect `id` with its defaults and `params` over them. +fn fx(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect { + vectorcraft_effects::new_effect(id, ¶ms).unwrap() +} + +/// A path `name` over `r` filled with `fill` and stroked 2 pt in `stroke` (none when `Paint::None`), +/// with `effects`. +fn styled_rect(doc: &mut Document, name: &str, r: Rect, fill: Paint, stroke: Paint, effects: Vec) -> Node { + let mut ap = Appearance::basic(fill, stroke, 2.0); + ap.effects = effects; + named(Node::path(doc.alloc_id(), shapes::rectangle(r), ap), name) +} + +/// A 400 × 300 document whose objects keep their live effects as art styles: a drop shadow in a +/// color and one by darkness, an inner and an outer glow in that order, Feather, Round Corners, +/// Scribble, Convert to Shape, Offset Path, Outline Stroke on an open path, a drop shadow on a +/// gradient fill and on a group with transparency, a hidden effect, Pathfinder › Add on a group, a +/// hidden locked object, an opacity mask, an object linked to a graphic style; and a graphic style +/// of its own with two fills, a stroke, item transparency and effects. +fn styles_document() -> Document { + let mut doc = Document::new(400.0, 300.0); + let layer = doc.layers[0].id; + let add = |doc: &mut Document, n: Node| { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + }; + let red = solid(Color::rgb(0.9, 0.2, 0.1)); + let blue = solid(Color::rgb(0.1, 0.3, 0.8)); + let shadow = fx("stylize.dropShadow", serde_json::json!({"x": 6.0, "y": 4.0, "blur": 8.0, "opacity": 60.0, "color": "#203040"})); + let n = styled_rect(&mut doc, "Shadowed", Rect::new(10.0, 10.0, 60.0, 50.0), red.clone(), blue.clone(), vec![shadow.clone()]); + add(&mut doc, n); + let dark = fx("stylize.dropShadow", serde_json::json!({"shade": "darkness", "darkness": 40.0, "mode": "darken"})); + let n = styled_rect(&mut doc, "Darkened", Rect::new(70.0, 10.0, 120.0, 50.0), blue.clone(), Paint::None, vec![dark]); + add(&mut doc, n); + let glows = vec![ + fx("stylize.innerGlow", serde_json::json!({"source": "center", "blur": 3.0})), + fx("stylize.outerGlow", serde_json::json!({"blur": 9.0, "opacity": 50.0})), + ]; + let n = styled_rect(&mut doc, "Glowing", Rect::new(130.0, 10.0, 180.0, 50.0), red.clone(), Paint::None, glows); + add(&mut doc, n); + let n = styled_rect( + &mut doc, + "Soft", + Rect::new(190.0, 10.0, 240.0, 50.0), + blue.clone(), + Paint::None, + vec![fx("stylize.feather", serde_json::json!({"radius": 7.0}))], + ); + add(&mut doc, n); + let round = fx("stylize.roundCorners", serde_json::json!({"radius": 9.0})); + let n = styled_rect(&mut doc, "Rounded", Rect::new(250.0, 10.0, 300.0, 50.0), red.clone(), blue.clone(), vec![round]); + add(&mut doc, n); + let n = styled_rect( + &mut doc, + "Scribbled", + Rect::new(310.0, 10.0, 360.0, 50.0), + blue.clone(), + Paint::None, + vec![fx("stylize.scribble", serde_json::json!({}))], + ); + add(&mut doc, n); + let shape = fx("convertToShape.roundedRectangle", serde_json::json!({"relative": true, "extraW": 12.0, "extraH": 8.0, "radius": 5.0})); + let n = styled_rect(&mut doc, "Shaped", Rect::new(10.0, 70.0, 60.0, 110.0), red.clone(), Paint::None, vec![shape]); + add(&mut doc, n); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 4.0, "joins": "miter", "miterLimit": 6.0})); + let n = styled_rect(&mut doc, "Offset", Rect::new(70.0, 70.0, 120.0, 110.0), blue.clone(), red.clone(), vec![offset]); + add(&mut doc, n); + let mut open = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((130.0, 110.0).into()), + kurbo::PathEl::LineTo((180.0, 70.0).into()), + ])); + open.subpaths.iter_mut().for_each(|sp| sp.closed = false); + let mut ap = Appearance::basic(Paint::None, red.clone(), 6.0); + ap.effects = vec![fx("path.outlineStroke", serde_json::json!({}))]; + let n = named(Node::path(doc.alloc_id(), open, ap), "Outlined"); + add(&mut doc, n); + let ramp = Gradient { + kind: GradientKind::Linear, + stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.6, 0.2))], + ..Gradient::default() + }; + let graded = Paint::Gradient(Box::new(GradientPaint::new(ramp))); + let n = styled_rect(&mut doc, "Graded", Rect::new(190.0, 70.0, 240.0, 110.0), graded, Paint::None, vec![shadow.clone()]); + add(&mut doc, n); + let mut hidden_fx = fx("stylize.outerGlow", serde_json::json!({})); + hidden_fx.visible = false; + let mut quiet = styled_rect(&mut doc, "Quiet", Rect::new(250.0, 70.0, 300.0, 110.0), blue.clone(), Paint::None, vec![hidden_fx]); + quiet.visible = false; + quiet.locked = true; + add(&mut doc, quiet); + // A group with a shadow, transparency and a mask; a group merged by Pathfinder › Add. + let a = square(&mut doc, Rect::new(10.0, 130.0, 50.0, 170.0), Color::rgb(0.2, 0.6, 0.3)); + let b = square(&mut doc, Rect::new(30.0, 150.0, 70.0, 190.0), Color::rgb(0.6, 0.2, 0.5)); + let mut shaded = see_through(group(&mut doc, vec![a, b], false), 0.5, BlendMode::Multiply); + shaded.appearance = Appearance { effects: vec![shadow.clone()], ..Appearance::default() }.into(); + let mask_art = square(&mut doc, Rect::new(10.0, 130.0, 70.0, 160.0), Color::gray(0.0)); + shaded.extras_mut().mask = Some(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true }.into()); + add(&mut doc, named(shaded, "Shaded group")); + let a = square(&mut doc, Rect::new(90.0, 130.0, 130.0, 170.0), Color::rgb(0.9, 0.6, 0.1)); + let b = square(&mut doc, Rect::new(110.0, 150.0, 150.0, 190.0), Color::rgb(0.9, 0.6, 0.1)); + let mut merged = group(&mut doc, vec![a, b], false); + merged.appearance = Appearance { effects: vec![fx("pathfinder.add", serde_json::json!({}))], ..Appearance::default() }.into(); + add(&mut doc, named(merged, "Merged")); + doc +} + +/// Each object's name, kind, transparency, state, mask and appearance, and whether it is linked to a +/// graphic style (by name), compared with [`writer::same_appearance`] apart. +fn style_view(doc: &Document) -> Vec<(String, Appearance)> { + let mut out = vec![]; + for n in doc.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()) { + let kind = match &n.kind { + NodeKind::Group { .. } => "group", + NodeKind::Path { .. } => "path", + NodeKind::Compound { .. } => "compound", + _ => "other", + }; + let link = n.extras().graphic_style.and_then(|id| doc.graphic_style_by_id(id)).map(|g| g.name.clone()); + let head = format!( + "{:?} {kind} {} {:?} isolate={} {:?} shown={} locked={} mask={} link={link:?} members={}", + n.name, + r3(f64::from(n.opacity)), + n.blend, + n.isolate, + n.knockout, + n.visible, + n.locked, + n.extras().mask.is_some(), + n.children().map_or(0, Vec::len) + ); + out.push((head, (*n.appearance).clone())); + } + out +} + +/// `ap` with its gradients' stops and kind only: their placement is the slice of gradients' (a +/// gradient comes back placed on the object, named after its definition). +fn unplaced(ap: &Appearance) -> Appearance { + let mut ap = ap.clone(); + for item in ap.items.iter_mut() { + let paint = match item { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Gradient(g) = paint { + (g.geom, g.swatch, g.angle) = (None, None, 0.0); + } + } + ap +} + +fn same_styles(got: &Document, want: &Document) { + let (g, w) = (style_view(got), style_view(want)); + same_lines(&g.iter().map(|(h, _)| h.clone()).collect::>(), &w.iter().map(|(h, _)| h.clone()).collect::>()); + for ((h, a), (_, b)) in g.iter().zip(&w) { + assert!(writer::same_appearance(&unplaced(a), &unplaced(b)), "{h}:\n got {a:?}\n want {b:?}"); + } + same_lines(&outlines(got), &outlines(want)); +} + +/// The points of `doc`'s objects and of their opacity masks, each path's fill rule and each +/// object's nesting, to the written precision. +fn outlines(doc: &Document) -> Vec { + fn walk(n: &Node, depth: usize, out: &mut Vec) { + let mut points = |path: &PathData, rule: Option| { + let mut s = format!("{depth} {:?} {rule:?}", n.name); + for sp in &path.subpaths { + s += &format!(" [closed={}", sp.closed); + for a in &sp.anchors { + s += &format!(" {:.2},{:.2}", a.p.x + 0.0, a.p.y + 0.0); + } + s += "]"; + } + out.push(s); + }; + match &n.kind { + NodeKind::Path { path, rule, .. } => points(path, Some(*rule)), + NodeKind::Compound { rule, children } => { + for c in children { + points(c.path_data().unwrap(), Some(*rule)); + } + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + out.push(format!("{depth} {:?} container", n.name)); + for c in children { + walk(c, depth + 1, out); + } + } + other => out.push(format!("{depth} {other:?}")), + } + if let Some(mask) = &n.extras().mask { + out.push(format!("{depth} mask clip={} invert={} disabled={} linked={}", mask.clip, mask.invert, mask.disabled, mask.linked)); + // The reader holds a mask's art in a group. + match &mask.art.kind { + NodeKind::Group { children, .. } if children.len() == 1 => walk(&children[0], depth + 1, out), + _ => walk(&mask.art, depth + 1, out), + } + } + } + let mut out = vec![]; + for l in doc.layers.iter() { + walk(l, 0, &mut out); + } + out +} + +/// Fails with the lines where `got` and `want` differ in more than their numbers' last written +/// decimal (a color read back from four decimals gives its other model's values again from them). +fn near_lines(got: &str, want: &str) { + let near = |a: &str, b: &str| { + let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect()); + a.len() == b.len() + && a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::(), y.trim_start_matches("%_").parse::()) { + (Ok(x), Ok(y)) => (x - y).abs() <= 2e-3, + _ => x == y, + }) + }; + let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect()); + let differ: Vec = (0..g.len().max(w.len())) + .filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b))) + .map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i))) + .collect(); + assert!(differ.is_empty(), "{}", differ.join("\n")); +} + +#[test] +fn objects_with_live_effects_are_read_back() { + let doc = styles_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + // A group's members come back as they were. + let members = |d: &Document, name: &str| { + let mut out = vec![]; + let g = d.layers[0].children().unwrap().iter().find(|n| n.name.as_deref() == Some(name)).unwrap().clone(); + view(g.children().unwrap(), 0, &mut out); + out + }; + same_lines(&members(&back, "Shaded group"), &members(&doc, "Shaded group")); + same_lines(&members(&back, "Merged"), &members(&doc, "Merged")); +} + +#[test] +fn art_styles_are_written_in_their_forms() { + let p = program_shown(&styles_document()); + // The styles in a non-printing block after the symbols' and before the palette. + let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Anon 1) /Name ,\n/ActiveStyle :\n").unwrap(); + assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}"); + assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}"); + // A drop shadow on the object, after its stack and before its transparency. + assert!( + p.contains( + "/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n1 /Int (blnd) ,\n0.6 /Real (opac) ,\n\ + 4 /Real (vert) ,\n6 /Real (horz) ,\n4 /Real (blur) ,\n/FillStyle : 0 O\n" + ), + "{p}" + ); + // The drawn look, its state block with the object's transparency, the object on `%_` lines + // painted with nothing, its name, and its style. + assert!(p.contains("U\n0 1 0 2 0 Xy\n0 0 Xd\n6 () XW\n%_0 XR\n%_0 1 0 0 0 Xy\n%_10 -10 m\n"), "{p}"); + assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Shadowed) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon 1) XW\n"), "{p}"); + assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Offset) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // A group's members on `%_` lines, then its name and style. + assert!(p.contains("6 () XW\n%_u\n"), "{p}"); + assert!(p.contains("%_U\n%_/ArtDictionary :\n%_/XMLUID : (Merged) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // Objects of the same appearance share their style. + let two = { + let mut doc = Document::new(200.0, 100.0); + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + for x in [10.0, 60.0] { + let n = styled_rect(&mut doc, "Twin", Rect::new(x, 10.0, x + 30.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow.clone()]); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + program(&doc) + }; + assert_eq!((two.matches("/KnownStyle :").count(), two.matches("1 (Anon 1) XW\n").count()), (1, 2), "{two}"); + assert!(!two.contains("%AI9_BeginArtStyleList"), "{two}"); +} + +/// Objects whose appearance the reader wouldn't read with their style are written as their +/// drawn look, with the warnings they were written with before; and a document without effects has +/// no art styles. +#[test] +fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + // Two fills and an effect: the reader keeps such an object's drawn look. + let mut two = styled_rect(&mut doc, "Two fills", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow.clone()]); + let mut ap = (*two.appearance).clone(); + ap.items.push(AppearanceItem::Fill(FillLayer { opacity: 0.5, ..FillLayer::new(solid(Color::WHITE)) })); + two.appearance = ap.into(); + doc.insert(Some(layer), usize::MAX, two).unwrap(); + // A warp on a path: the reader keeps a path's drawn look for it. + let warped = styled_rect( + &mut doc, + "Warped", + Rect::new(50.0, 10.0, 80.0, 40.0), + solid(Color::BLACK), + Paint::None, + vec![fx("warp.arc", serde_json::json!({}))], + ); + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = String::from_utf8(out.program).unwrap(); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.lines().any(|l| l.starts_with("1 (")), "{p}"); + let back = read_back(&doc); + assert!(back.layers[0].children().unwrap().iter().all(|n| n.appearance.effects.is_empty())); + // Nothing styled: no art styles. + assert!(!program(&sample()).contains("ArtStyle")); +} + +/// A compound shape draws as one area, which an art style's object wouldn't keep: it and a group +/// holding one are written as their drawn look, with a warning. +#[test] +fn compound_shapes_are_not_written_with_an_art_style() { + let shadow = || fx("stylize.dropShadow", serde_json::json!({})); + let shape = |doc: &mut Document| { + let a = square(doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK); + let b = square(doc, Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK); + let mut g = group(doc, vec![a, b], false); + g.extras_mut().compound_shape = true; + g + }; + let mut own = Document::new(200.0, 100.0); + let mut n = shape(&mut own); + n.appearance = Appearance { effects: vec![shadow()], ..Appearance::default() }.into(); + let layer = own.layers[0].id; + own.insert(Some(layer), usize::MAX, n).unwrap(); + let mut nested = Document::new(200.0, 100.0); + let member = shape(&mut nested); + let mut n = group(&mut nested, vec![member], false); + n.appearance = Appearance { effects: vec![shadow()], ..Appearance::default() }.into(); + let layer = nested.layers[0].id; + nested.insert(Some(layer), usize::MAX, n).unwrap(); + for doc in [own, nested] { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("1 (Anon 1) XW")); + } +} + +/// A member of a group whose Transform effect moves it is written where baking put it, not with +/// its own effects where it was. +#[test] +fn a_styled_member_of_a_moved_group_stays_where_it_draws() { + fn left_of_paths(n: &Node) -> f64 { + let own = n.path_data().map_or(f64::INFINITY, |p| kurbo::Shape::bounding_box(&p.to_bezpath()).x0); + n.children().map_or(own, |c| c.iter().map(|m| left_of_paths(m)).fold(own, f64::min)) + } + let mut doc = Document::new(300.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + let member = styled_rect(&mut doc, "Member", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow]); + let mut moved = group(&mut doc, vec![member], false); + moved.appearance = Appearance { effects: vec![fx("distort.transform", serde_json::json!({"moveH": 70.0}))], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, moved).unwrap(); + let back = read_back(&doc); + let left = left_of_paths(&back.layers[0]); + assert!((left - 80.0).abs() < 1e-3, "the art starts at x = {left}"); +} + +/// A hidden, locked styled group keeps its state when its members are shown and unlocked. +#[test] +fn a_styled_group_keeps_its_state_after_members_in_another() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let member = square(&mut doc, Rect::new(10.0, 10.0, 40.0, 40.0), Color::BLACK); + let mut shaded = group(&mut doc, vec![member], false); + shaded.appearance = Appearance { effects: vec![fx("stylize.dropShadow", serde_json::json!({}))], ..Appearance::default() }.into(); + shaded.visible = false; + shaded.locked = true; + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Group { .. })); + assert_eq!(n.appearance.effects.len(), 1); + assert!(!n.visible && n.locked, "visible {} locked {}", n.visible, n.locked); +} + +/// Effects in a styled object's opacity mask are not its style's: leaving them out warns. +#[test] +fn effects_in_a_styled_objects_mask_are_warned_about() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + let mut n = styled_rect(&mut doc, "Masked", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow]); + let blurred = styled_rect( + &mut doc, + "Mask art", + Rect::new(10.0, 10.0, 40.0, 25.0), + solid(Color::gray(0.0)), + Paint::None, + vec![fx("blur.gaussian", serde_json::json!({}))], + ); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(blurred), clip: true, invert: false, disabled: false, linked: true }.into()); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(shown(&out.program).contains("\n1 (Anon 1) XW\n")); +} + +/// On a large canvas no art style is written (the reader scales the styles of a large canvas): +/// objects with effects are written as their vector art with their geometry effects applied, with a +/// warning. +#[test] +fn a_large_canvas_writes_no_art_styles() { + let mut doc = Document::new(20000.0, 100.0); + let n = styled_rect( + &mut doc, + "Far", + Rect::new(10.0, 10.0, 40.0, 40.0), + solid(Color::BLACK), + Paint::None, + vec![fx("stylize.feather", serde_json::json!({}))], + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES), "{:?}", out.warnings); + assert!(!String::from_utf8(out.program).unwrap().contains("ArtStyle")); +} + +#[test] +fn art_styles_write_the_same_bytes_again() { + let doc = styles_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert!(shown(&first.program).contains("\n1 (Anon 1) XW\n")); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); + // What is read back is written as it was. + let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; + near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program))); +} + +/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of +/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and +/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this +/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can +/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float +/// bits, as it does between the first save and the save of the reopened file, and the platform's +/// float math decides which. The RGB values stay compared. +fn rgb_without_cmyk(program: &str) -> String { + let blank = |line: &str| { + let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r)); + let t: Vec<&str> = rest.split(' ').collect(); + let color = t.len() == 8 && matches!(t[7], "Xa" | "XA"); + let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs"); + if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() } + }; + program.lines().map(blank).collect::>().join("\n") +} + +/// `n` on the first layer of `doc`. +fn put(doc: &mut Document, n: Node) { + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); +} + +fn drop_shadow() -> vectorcraft_doc::Effect { + fx("stylize.dropShadow", serde_json::json!({})) +} + +fn black_fill() -> Paint { + solid(Color::BLACK) +} + +/// An even-odd compound path of a square inside a square, with `ap`. +fn ring(doc: &mut Document, ap: Appearance) -> Node { + let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(70.0, 10.0, 110.0, 50.0)), Appearance::default()); + let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(80.0, 20.0, 100.0, 40.0)), Appearance::default()); + let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd }); + n.appearance = ap.into(); + n +} + +/// A path filled black with no stroke and `effects`. +fn plain_rect(doc: &mut Document, name: &str, effects: Vec) -> Node { + styled_rect(doc, name, Rect::new(10.0, 10.0, 50.0, 40.0), black_fill(), Paint::None, effects) +} + +/// A 200 × 100 document's first object as it reads back from a document of only `n`. +fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc, Vec, String) { + let mut doc = Document::new(200.0, 100.0); + let n = make(&mut doc); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let back = read_back(&doc); + (back.layers[0].children().unwrap()[0].clone(), out.warnings, shown(&out.program)) +} + +/// A filled path or compound path with live effects that fills by the even-odd rule is written as its +/// drawn look with a warning, since a style's fill fills by the non-zero rule. A stroke-only object +/// keeps its style and its own fill rule. +#[test] +fn a_filled_even_odd_object_is_written_as_its_drawn_look() { + let mut doc = Document::new(300.0, 100.0); + let mut odd = plain_rect(&mut doc, "Odd", vec![drop_shadow()]); + if let NodeKind::Path { rule, .. } = &mut odd.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, odd); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let n = named(ring(&mut doc, ap), "Ring"); + put(&mut doc, n); + let n = plain_rect(&mut doc, "Even", vec![drop_shadow()]); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = shown(&out.program); + assert_eq!((p.matches("/KnownStyle :").count(), p.matches("\n1 (Anon 1) XW\n").count()), (1, 1), "{p}"); + assert!(!p.contains("1 /Bool (UseEvenOdd)"), "{p}"); + + let mut doc = Document::new(300.0, 100.0); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(Paint::None, solid(Color::BLACK), 2.0) }; + let n = named(ring(&mut doc, ap), "Stroked"); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. })); +} + +/// A compound path filled by the non-zero rule keeps its effects and its gradient fill. +#[test] +fn a_compound_path_keeps_its_style() { + let mut doc = Document::new(300.0, 100.0); + let non_zero = |mut n: Node| { + if let NodeKind::Compound { rule, .. } = &mut n.kind { + *rule = FillRule::NonZero; + } + n + }; + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), solid(Color::WHITE), 2.0) }; + let n = named(non_zero(ring(&mut doc, ap)), "Shadowed"); + put(&mut doc, n); + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(graded, Paint::None, 0.0) }; + let n = named(non_zero(ring(&mut doc, ap)), "Graded"); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + assert!(back.layers[0].children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Compound { .. }))); +} + +/// A group holding an image is written as its drawn look: the image stays a placed file, once. +#[test] +fn a_group_holding_an_image_is_written_as_its_drawn_look() { + let mut doc = Document::new(200.0, 100.0); + let link = Some(vectorcraft_doc::LinkInfo::new("/tmp/ramp.png")); + let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(10.0), link); + let mut g = group(&mut doc, vec![im], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::LINKS_EMBEDDED), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1)")); + let back = read_back(&doc); + let g = &back.layers[0].children().unwrap()[0]; + let member = g.children().map_or(g, |c| &c[0]); + let NodeKind::Image(im) = &member.kind else { panic!("{member:?}") }; + assert!(im.link.is_some()); +} + +/// Graphic styles and links to them are left out with a warning: an object linked to a style +/// keeps its effects in a style of its own. A new document's styles without a link don't warn. +#[test] +fn graphic_styles_and_links_are_left_out_with_a_warning() { + let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::GRAPHIC_STYLES); + assert!(!warns(&Document::new(200.0, 100.0))); + // A style of the document's own. + let mut own = Document::new(200.0, 100.0); + own.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0))); + assert!(warns(&own)); + assert!(!shown(&export_ai_editing(&own, &opts()).unwrap().program).contains("ArtStyle")); + assert_eq!(read_back(&own).graphic_style("Mine"), None); + // A link to a new document's style, on an object with an effect. + let mut linked = Document::new(200.0, 100.0); + let id = linked.graphic_style("Sunshine").unwrap().id; + let mut n = plain_rect(&mut linked, "Linked", vec![drop_shadow()]); + n.extras_mut().graphic_style = Some(id); + put(&mut linked, n); + assert!(warns(&linked)); + let back = read_back(&linked); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style, None); + assert_eq!(n.appearance.effects.len(), 1); + // A link in a symbol's art. + let mut in_symbol = Document::new(200.0, 100.0); + let id = in_symbol.graphic_style("Sunshine").unwrap().id; + let mut art = square(&mut in_symbol, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); + art.extras_mut().graphic_style = Some(id); + in_symbol.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + assert!(warns(&in_symbol)); +} + +/// A path or compound path with no points writes nothing, with or without a style; a group with no +/// members keeps its effects. +#[test] +fn objects_with_nothing_to_write_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + let mut empty = plain_rect(&mut doc, "Empty", vec![drop_shadow()]); + if let NodeKind::Path { path, .. } = &mut empty.kind { + path.subpaths.clear(); + } + put(&mut doc, empty); + let mut hollow = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![], rule: FillRule::NonZero }); + hollow.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into(); + put(&mut doc, named(hollow, "Hollow")); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = shown(&out.program); + assert!(!p.contains("(Anon 1)") && !p.contains("(Empty)") && !p.contains("6 () XW"), "{p}"); + assert_eq!(read_back(&doc).layers[0].children().unwrap().len(), 0); + + let (n, ..) = read_one(|doc| { + let mut g = group(doc, vec![], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + named(g, "Bare") + }); + assert_eq!((n.name.as_deref(), n.appearance.effects.len(), n.children().map(Vec::len)), (Some("Bare"), 1, Some(0))); +} + +/// Outline Object, every Convert to Shape form and size, Offset Path's joins, Inner Glow's sources +/// and a stroke's width profile, each on an object, read back as they are. Drop Shadow and the glows +/// keep their colors, in CMYK too. +#[test] +fn every_effect_setting_written_on_an_object_is_read_back() { + use serde_json::json; + let cases = [ + ("path.outlineObject", json!({})), + ("path.outlineStroke", json!({})), + ("convertToShape.rectangle", json!({"relative": true, "extraW": 6.0, "extraH": 4.0})), + ("convertToShape.rectangle", json!({"relative": false, "width": 30.0, "height": 20.0})), + ("convertToShape.roundedRectangle", json!({"relative": false, "width": 30.0, "height": 20.0, "radius": 3.0})), + ("convertToShape.roundedRectangle", json!({"relative": true, "extraW": 6.0, "extraH": 4.0, "radius": 3.0})), + ("convertToShape.ellipse", json!({"relative": false, "width": 30.0, "height": 20.0})), + ("path.offsetPath", json!({"offset": 3.0, "joins": "round", "miterLimit": 4.0})), + ("path.offsetPath", json!({"offset": -3.0, "joins": "bevel", "miterLimit": 4.0})), + ("path.offsetPath", json!({"offset": 3.0, "joins": "miter", "miterLimit": 9.0})), + ("stylize.innerGlow", json!({"source": "edge", "blur": 2.5, "opacity": 40.0, "mode": "screen", "color": "#102030"})), + ("stylize.innerGlow", json!({"source": "center", "blur": 2.5})), + ("stylize.outerGlow", json!({"blur": 7.5, "opacity": 33.333, "mode": "normal", "color": "#a0b0c0"})), + ("stylize.dropShadow", json!({"x": 1.25, "y": -2.5, "blur": 3.5, "opacity": 12.5, "mode": "overlay", "color": "#405060"})), + ("stylize.dropShadow", json!({"shade": "darkness", "darkness": 62.5, "x": 0.0, "y": 0.0, "blur": 0.0})), + ("stylize.feather", json!({"radius": 11.5})), + ("stylize.roundCorners", json!({"radius": 0.5})), + ( + "stylize.scribble", + json!({"angle": 15.0, "overlap": 2.0, "overlapVariation": 1.0, "strokeWidth": 1.5, "curviness": 12.5, "curvinessVariation": 7.5, "spacing": 4.0, "spacingVariation": 1.5}), + ), + ]; + for (id, params) in cases { + let effect = fx(id, params.clone()); + let (n, warnings, p) = read_one(|doc| plain_rect(doc, "One", vec![effect.clone()])); + assert!(!warnings.iter().any(|w| w == writer::EFFECTS), "{id} {params}: {warnings:?}\n{p}"); + assert_eq!(n.appearance.effects.len(), 1, "{id} {params}"); + let (got, want) = (&n.appearance.effects[0], &effect); + assert_eq!(got.id, want.id); + let (g, w) = (vectorcraft_effects::merged_params(&got.id, &got.params), vectorcraft_effects::merged_params(&want.id, &want.params)); + for (key, value) in w.as_object().unwrap() { + let read = g.get(key).unwrap_or(&serde_json::Value::Null); + match (key.as_str(), value.as_f64()) { + // A case the file doesn't keep: `seed` and the like, which the reader fills in as their defaults. + ("mode" | "shade" | "source" | "joins", _) => { + let lower = |v: &serde_json::Value| v.as_str().unwrap_or_default().to_ascii_lowercase(); + assert_eq!(lower(read), lower(value), "{id} {key}"); + } + ("color", _) => { + let color = |p: &serde_json::Value| vectorcraft_effects::color_param(p, Color::BLACK).to_hex(); + assert_eq!(color(&g), color(&w), "{id}"); + } + (_, Some(v)) => assert!((read.as_f64().unwrap() - v).abs() < 6e-3, "{id} {key}: {read} for {v}"), + _ => assert_eq!(read, value, "{id} {key}"), + } + } + } + // A relative Ellipse: the reader keeps the drawn look, which is the effect's result. + let effect = fx("convertToShape.ellipse", json!({"relative": true, "extraW": 6.0, "extraH": 4.0})); + let (n, _, p) = read_one(|doc| plain_rect(doc, "One", vec![effect.clone()])); + assert!(!p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(n.appearance.effects.is_empty()); + // A stroke's width profile. + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let (n, warnings, p) = read_one(|doc| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![drop_shadow()], contents_index: None }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }); + assert!(!warnings.iter().any(|w| w == writer::EFFECTS), "{warnings:?}"); + assert!(p.contains("(BSVariableWidthTValuesArray)") && p.contains("(BSLeftWidthsArray)") && p.contains("(BSRightWidthsArray)"), "{p}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile)); + // A CMYK document: a CMYK and a spot fill, a CMYK stroke, and an effect's CMYK color stay as they are. + let mut doc = Document::new(200.0, 100.0); + doc.color_mode = vectorcraft_doc::ColorMode::Cmyk; + let mut swatches = (*doc.swatches).clone(); + swatches.push(Swatch { name: "Ink".into(), paint: solid(INK), global: true, spot: true }); + swatches.push(Swatch { name: "Brand".into(), paint: solid(BRAND), global: true, spot: false }); + doc.swatches = swatches.into(); + let shadow = fx("stylize.dropShadow", json!({"color": serde_json::to_value(GOLD).unwrap()})); + let n = styled_rect(&mut doc, "Process", Rect::new(10.0, 10.0, 40.0, 40.0), solid(GOLD), solid(BRAND), vec![shadow.clone()]); + put(&mut doc, n); + let n = styled_rect(&mut doc, "Spot", Rect::new(50.0, 10.0, 80.0, 40.0), linked("Ink", INK, 0.5), Paint::None, vec![shadow.clone()]); + put(&mut doc, n); + let n = styled_rect(&mut doc, "Global", Rect::new(90.0, 10.0, 120.0, 40.0), linked("Brand", BRAND, 1.0), Paint::None, vec![shadow]); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + for n in back.layers[0].children().unwrap() { + assert!(matches!(vectorcraft_effects::color_param(&n.appearance.effects[0].params, Color::BLACK), Color::Cmyk { .. }), "{n:?}"); + } + let spot = &back.layers[0].children().unwrap()[1]; + assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); +} + +/// Every kind of appearance a style isn't written for is written as its drawn look, with a warning. +#[test] +fn appearances_written_as_their_drawn_look() { + type Make = Box Node>; + let cases: Vec<(&str, Make)> = vec![ + ( + "a pattern fill", + Box::new(|doc| { + let tile = square(doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![Arc::new(tile)]); + dots.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(dots); + let fill = Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }; + styled_rect(doc, "P", Rect::new(10.0, 10.0, 50.0, 40.0), fill, Paint::None, vec![drop_shadow()]) + }), + ), + ( + "an overprinting fill", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { overprint: true, ..FillLayer::new(black_fill()) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "an overprinting stroke", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { overprint: true, ..StrokeLayer::new(black_fill(), 2.0) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "a gradient stroke", + Box::new(|doc| { + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + styled_rect(doc, "G", Rect::new(10.0, 10.0, 50.0, 40.0), Paint::None, graded, vec![drop_shadow()]) + }), + ), + ( + "a fill with an opacity of its own", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { opacity: 0.5, ..FillLayer::new(black_fill()) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "a fill with an effect of its own", + Box::new(|doc| { + let fill = FillLayer { effects: vec![fx("stylize.feather", serde_json::json!({}))], ..FillLayer::new(black_fill()) }; + let ap = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Appearance::default() }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ("Pathfinder on a path", Box::new(|doc| plain_rect(doc, "P", vec![fx("pathfinder.add", serde_json::json!({}))]))), + ( + "a group with a fill of its own", + Box::new(|doc| { + let member = square(doc, Rect::new(10.0, 10.0, 40.0, 40.0), Color::BLACK); + let mut g = group(doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(solid(Color::WHITE), Paint::None, 0.0) }.into(); + g + }), + ), + ("an effect the style doesn't hold", Box::new(|doc| plain_rect(doc, "B", vec![fx("blur.gaussian", serde_json::json!({}))]))), + ( + "a group whose member's stroke is written as its outline", + Box::new(|doc| { + let mut stroke = StrokeLayer::new(solid(Color::BLACK), 4.0); + stroke.profile = Some(vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Appearance::default() }; + let member = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), ap); + let mut g = group(doc, vec![member], false); + let effects = vec![fx("stylize.roundCorners", serde_json::json!({})), drop_shadow()]; + g.appearance = Appearance { effects, ..Appearance::default() }.into(); + g + }), + ), + ( + "a group whose effects reshape a member filled with pattern tiles", + Box::new(|doc| { + let big = square(doc, Rect::new(-2.0, -2.0, 6.0, 6.0), Color::rgb(1.0, 0.0, 0.0)); + let mut over = vectorcraft_doc::PatternDef::new("Over", vec![Arc::new(big)]); + over.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(over); + let tiles = Paint::Pattern { pattern: "Over".into(), xf: Affine::IDENTITY }; + let member = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::basic(tiles, Paint::None, 0.0)); + let mut g = group(doc, vec![member], false); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 3.0})); + g.appearance = Appearance { effects: vec![offset, drop_shadow()], ..Appearance::default() }.into(); + g + }), + ), + ( + "a group whose effects reshape a member with a gradient that fits it", + Box::new(|doc| { + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let member = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(30.0, 10.0, 100.0, 40.0)), Appearance::basic(graded, Paint::None, 0.0)); + let mut g = group(doc, vec![member], false); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 15.0})); + g.appearance = Appearance { effects: vec![offset, drop_shadow()], ..Appearance::default() }.into(); + g + }), + ), + ( + "Define Knockout Shape", + Box::new(|doc| { + let mut n = plain_rect(doc, "K", vec![drop_shadow()]); + n.knockout_shape = true; + n + }), + ), + ]; + for (what, make) in cases { + let mut doc = Document::new(200.0, 100.0); + let n = make(&mut doc); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{what}: {:?}", out.warnings); + let p = shown(&out.program); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.contains("(Anon 1) XW"), "{what}:\n{p}"); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty(), "{what}: {n:?}"); + } +} + +/// A gradient fill that fits its object is placed on the outline its geometry effects draw: an +/// object with Offset Path reads back with the gradient a plain object of the drawn size has. +#[test] +fn a_styled_objects_gradient_fits_its_drawn_outline() { + let stops = || vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops())))); + let first = |doc: &Document| doc.layers[0].children().unwrap()[0].clone(); + let mut doc = Document::new(200.0, 100.0); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 20.0, "joins": "miter"})); + let n = styled_rect(&mut doc, "G", Rect::new(50.0, 30.0, 100.0, 60.0), graded(), Paint::None, vec![offset]); + put(&mut doc, n); + let styled = first(&read_back(&doc)); + assert_eq!(styled.appearance.effects.len(), 1); + let mut doc = Document::new(200.0, 100.0); + let n = styled_rect(&mut doc, "P", Rect::new(30.0, 10.0, 120.0, 80.0), graded(), Paint::None, vec![]); + put(&mut doc, n); + let plain = first(&read_back(&doc)); + let geom = |n: &Node| match &n.appearance.fill().unwrap().paint { + Paint::Gradient(g) => { + let g = g.geom.unwrap(); + format!("{:.2} {:.2} {:.2} {:.2} {:.2}", g.start.x, g.start.y, g.end.x, g.end.y, g.aspect) + } + other => panic!("{other:?}"), + }; + assert_eq!(geom(&styled), geom(&plain)); +} + +/// The effects of a pattern's tile art are its own: a styled group's drawn look that first uses the +/// pattern still warns that the tile's Gaussian Blur is left out. +#[test] +fn effects_in_a_pattern_a_styled_look_uses_are_warned_about() { + let mut doc = Document::new(200.0, 100.0); + let dot = styled_rect( + &mut doc, + "Dot", + Rect::new(2.0, 2.0, 8.0, 8.0), + solid(Color::BLACK), + Paint::None, + vec![fx("blur.gaussian", serde_json::json!({}))], + ); + let mut grid = vectorcraft_doc::PatternDef::new("Grid", vec![Arc::new(dot)]); + grid.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(grid); + let tiles = Paint::Pattern { pattern: "Grid".into(), xf: Affine::IDENTITY }; + let member = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::basic(tiles, Paint::None, 0.0)); + let mut g = group(&mut doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(p.contains("%AI3_BeginPattern") && p.contains("\n1 (Anon 1) XW\n"), "{p}"); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); +} + +/// A style that isn't written defines no gradient: a hidden gradient fill that only the style +/// would have held leaves no gradient the art uses. +#[test] +fn a_style_that_isnt_written_defines_no_gradient() { + let mut doc = Document::new(200.0, 100.0); + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let ap = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer { visible: false, ..FillLayer::new(graded) }), + AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 2.0)), + ], + effects: vec![fx("blur.gaussian", serde_json::json!({}))], + contents_index: None, + }; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(!p.contains("(Anon 1) XW"), "{p}"); + assert!(p.contains("%%BeginSetup\n0 Bn\n"), "{p}"); +} + +/// An object in a plain group, a sublayer or a clipping group by a path keeps its style there. One +/// in a symbol's art, in an opacity mask or in art cut to a clipping object other than a path is +/// written as its drawn look, with a warning. +#[test] +fn where_a_styled_object_keeps_its_style() { + let inside = |doc: &mut Document| plain_rect(doc, "Inside", vec![drop_shadow()]); + let found = |doc: &Document| { + fn find(nodes: &[Arc]) -> Option<&Arc> { + nodes.iter().find_map(|n| if n.name.as_deref() == Some("Inside") { Some(n) } else { n.children().and_then(|c| find(c)) }) + } + find(&doc.layers).cloned() + }; + // In a plain group. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + let g = group(&mut doc, vec![n], false); + put(&mut doc, g); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + same_styles(&read_back(&doc), &doc); + // In a sublayer. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + let mut sub = Node::layer(doc.alloc_id(), "Sub", LayerColor::Preset(1)); + sub.children_mut().unwrap().push(Arc::new(n)); + put(&mut doc, sub); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + // In a clipping group. + let mut doc = Document::new(200.0, 100.0); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 60.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let n = inside(&mut doc); + let g = group(&mut doc, vec![clip, n], true); + put(&mut doc, g); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + // In a symbol's art: the instance keeps the symbol, the effects are left out. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(n) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }); + put(&mut doc, instance); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); + // In an opacity mask. + let mut doc = Document::new(200.0, 100.0); + let art = inside(&mut doc); + let mut n = square(&mut doc, Rect::new(10.0, 10.0, 60.0, 60.0), Color::BLACK); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into()); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); + // In art cut to a clipping object other than a path: type. + let mut doc = Document::new(200.0, 100.0); + let t = vectorcraft_doc::TextObject::point( + vectorcraft_geom::Point::new(10.0, 70.0), + "Type", + vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }, + ); + let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + let n = inside(&mut doc); + let g = group(&mut doc, vec![t, n], true); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); +} + +/// A styled group's members of every kind allowed come back as they were: a compound path, a named +/// path, a hidden and a locked path, a nested group and a clipping group. +#[test] +fn a_styled_groups_members_of_each_kind_are_read_back() { + let mut doc = Document::new(200.0, 100.0); + let solid_ap = Appearance::basic(solid(Color::rgb(0.2, 0.4, 0.6)), Paint::None, 0.0); + let compound = named(ring(&mut doc, solid_ap.clone()), "Compound"); + let plain = named(square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.1, 0.1)), "Named"); + let mut quiet = named(square(&mut doc, Rect::new(30.0, 10.0, 50.0, 30.0), Color::rgb(0.1, 0.8, 0.1)), "Hidden"); + quiet.visible = false; + let mut fixed = named(square(&mut doc, Rect::new(50.0, 10.0, 70.0, 30.0), Color::rgb(0.1, 0.1, 0.8)), "Locked"); + fixed.locked = true; + let a = square(&mut doc, Rect::new(10.0, 40.0, 30.0, 60.0), Color::BLACK); + let nested = named(group(&mut doc, vec![a], false), "Nested"); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let b = square(&mut doc, Rect::new(40.0, 40.0, 80.0, 80.0), Color::WHITE); + let cut = named(group(&mut doc, vec![clip, b], true), "Cut"); + let mut g = named(group(&mut doc, vec![compound, plain, quiet, fixed, nested, cut], false), "Holder"); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + let members = |d: &Document| { + let mut out = vec![]; + view(d.layers[0].children().unwrap()[0].children().unwrap(), 0, &mut out); + out + }; + same_lines(&members(&back), &members(&doc)); +} + +/// What compares two appearances for the writer's checks and for these tests tells them apart. +#[test] +fn same_appearance_tells_appearances_apart() { + use serde_json::json; + let base = || Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.5, 0.25, 0.75)))), + AppearanceItem::Stroke(StrokeLayer::new(black_fill(), 2.0)), + ], + effects: vec![fx("stylize.dropShadow", json!({"x": 10.0, "mode": "Multiply", "color": "#102030"}))], + contents_index: None, + }; + let same = writer::same_appearance; + assert!(same(&base(), &base())); + // Within the written precision, a mode in another case and a color in another form. + let mut other = base(); + other.effects[0].params["x"] = json!(10.005); + other.effects[0].params["mode"] = json!("multiply"); + other.effects[0].params["color"] = serde_json::to_value(Color::from_hex("#102030").unwrap()).unwrap(); + assert!(same(&other, &base())); + // A fill or stroke that doesn't paint. + let mut other = base(); + other.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + assert!(same(&other, &base())); + type Change = Box; + let change: Vec = vec![ + Box::new(|a| a.effects[0].params["x"] = json!(10.01)), + Box::new(|a| a.effects[0].params["color"] = json!("#102031")), + Box::new(|a| a.effects[0].params["mode"] = json!("screen")), + Box::new(|a| a.effects[0].visible = false), + Box::new(|a| a.effects[0].id = "stylize.outerGlow".into()), + Box::new(|a| a.effects.clear()), + Box::new(|a| a.items.truncate(1)), + Box::new(|a| a.items.swap(0, 1)), + Box::new(|a| a.contents_index = Some(2)), + Box::new(|a| { + if let AppearanceItem::Fill(f) = &mut a.items[0] { + f.paint = solid(Color::rgb(0.5, 0.25, 0.76)); + } + }), + Box::new(|a| { + if let AppearanceItem::Fill(f) = &mut a.items[0] { + f.opacity = 0.5; + } + }), + Box::new(|a| { + if let AppearanceItem::Stroke(s) = &mut a.items[1] { + s.width = 2.5; + } + }), + ]; + for (i, f) in change.iter().enumerate() { + let mut other = base(); + f(&mut other); + assert!(!same(&other, &base()), "change {i}"); + } +} + +/// A gradient fill in a style is placed as on an object without a style: a turned linear gradient +/// and a radial one with a focal point, and on a first artboard away from the origin among several. +#[test] +fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() { + use vectorcraft_color::gradient::GradientGeom; + let stops = || vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::rgb(0.1, 0.2, 0.8))]; + let turned = GradientGeom { start: (120.0, 60.0).into(), end: (170.0, 90.0).into(), aspect: 1.0, focal: None }; + let mut focal = GradientGeom { start: (140.0, 70.0).into(), end: (170.0, 70.0).into(), aspect: 0.5, focal: None }; + focal.set_focal(Some((150.0, 65.0).into())); + let boards = [Rect::new(0.0, 0.0, 300.0, 200.0), Rect::new(100.0, 50.0, 400.0, 250.0)]; + for (kind, geom) in [(GradientKind::Linear, turned), (GradientKind::Radial, focal)] { + for board in boards { + let paint = gradient_paint(Gradient::new(kind, stops()), Some(geom), None); + let make = |effects: Vec| { + let mut doc = Document::new(300.0, 200.0); + doc.artboards[0].rect = board; + let second = doc.artboards[0].clone(); + doc.artboards.push(Artboard { rect: board + vectorcraft_geom::Vec2::new(400.0, 0.0), ..second }); + let n = styled_rect(&mut doc, "G", Rect::new(120.0, 60.0, 180.0, 100.0), paint.clone(), Paint::None, effects); + put(&mut doc, n); + doc + }; + let (with, without) = (read_back(&make(vec![drop_shadow()])), read_back(&make(vec![]))); + let (a, b) = (&with.layers[0].children().unwrap()[0], &without.layers[0].children().unwrap()[0]); + assert_eq!(a.appearance.effects.len(), 1); + let geom = |n: &Node| match &n.appearance.fill().unwrap().paint { + Paint::Gradient(g) => { + let g = g.geom.unwrap(); + format!( + "{:.2} {:.2} {:.2} {:.2} {:.2} {:?}", + g.start.x, + g.start.y, + g.end.x, + g.end.y, + g.aspect, + g.focal.map(|f| (r3(f.x), r3(f.y))) + ) + } + other => panic!("{other:?}"), + }; + assert_eq!(geom(a), geom(b)); + same_lines(&outlines(&with), &outlines(&without)); + } + } +} + +/// On a large canvas an object with an effect warns and comes back as its drawn look at its place. +#[test] +fn a_large_canvas_writes_every_object_as_its_look() { + let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES); + let mut plain = Document::new(20000.0, 100.0); + let n = plain_rect(&mut plain, "None", vec![]); + put(&mut plain, n); + assert!(!warns(&plain)); + let mut shaded = Document::new(20000.0, 100.0); + let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]); + put(&mut shaded, n); + assert!(warns(&shaded)); + let back = read_back(&shaded); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty()); + let p = n.path_data().unwrap().subpaths[0].anchors[0].p; + assert!((p.x - 10.0).abs() < 1e-2 && (p.y - 10.0).abs() < 1e-2, "{p:?}"); +} + +// ---------- variables ---------- + +/// A 200 × 100 document with variables: a text variable `name` bound to point type, a visibility +/// variable `show` bound to an unnamed square and to a rectangle `Badge`, an image variable +/// `picture` bound to a linked image, a graph variable `chart` and a variable of unknown kind +/// `other`; the data sets `First` (two lines of text, hidden, a linked file in a folder with a space +/// and a `%`) and `Second` (empty text, shown, a linked file on a drive), `Second` shown; the +/// variables locked. +fn variables_doc() -> Document { + use vectorcraft_doc::{DataSet, DataValue, Variable, VariableKind}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let square = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(solid(GOLD), Paint::None, 0.0)); + let mut badge = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(40.0, 10.0, 90.0, 30.0)), Appearance::basic(solid(BRAND), Paint::None, 0.0)); + badge.name = Some("Badge".into()); + let pic = image_node( + &mut doc, + ImageBlob::png(rgb_png()), + (3, 2), + Affine::new([10.0, 0.0, 0.0, 10.0, 100.0, 10.0]), + Some(vectorcraft_doc::LinkInfo::new("/tmp/a b/one.png")), + ); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Ann", style(18.0, Color::BLACK)); + let t = text_node(&mut doc, t); + let ids = [square.id, badge.id, pic.id, t.id]; + for n in [square, badge, pic, t] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let vars = &mut doc.variables; + for (name, kind) in [ + ("name", VariableKind::Text), + ("show", VariableKind::Visibility), + ("picture", VariableKind::Image), + ("chart", VariableKind::Graph), + ("other", VariableKind::Unknown), + ] { + vars.variables.push(Variable { name: name.into(), kind }); + } + vars.bind(ids[0], "show"); + vars.bind(ids[1], "show"); + vars.bind(ids[2], "picture"); + vars.bind(ids[3], "name"); + let row = |name: &str, text: &str, show: bool, file: &str| DataSet { + name: name.into(), + values: [ + ("name".to_string(), DataValue::Text(text.into())), + ("show".to_string(), DataValue::Visible(show)), + ("picture".to_string(), DataValue::Image(file.into())), + ] + .into(), + }; + vars.datasets = vec![row("First", "Ann (one)\nLee & é", false, "/Users/test/Job 100%/pic.png"), row("Second", "", true, "C:\\Art\\two.png")]; + vars.active_dataset = Some("Second".into()); + vars.locked = true; + doc +} + +/// The objects of `doc` bound to each variable, by kind and name, in document order. +fn bound_objects(doc: &Document) -> Vec { + let mut out = vec![]; + for v in &doc.variables.variables { + for id in doc.variables.objects_of(&v.name) { + let n = doc.node(id).unwrap(); + let kind = match &n.kind { + NodeKind::Image(im) => format!("image {:?}", im.link.as_ref().map(|l| l.path.clone())), + NodeKind::Path { .. } => "path".to_string(), + NodeKind::Text(t) => format!("type {:?}", t.runs.iter().map(|r| r.text.as_str()).collect::()), + _ => "other".to_string(), + }; + out.push(format!("{} → {kind} {:?}", v.name, n.name)); + } + } + out +} + +/// The warnings the reader gives about the variable library of `doc`'s editing data. +fn library_notes(doc: &Document) -> Vec { + let (_, warnings) = crate::ai_alone(&private_data(doc, &opts()).unwrap().0.data).unwrap(); + warnings.into_iter().filter(|w| w.contains("variable")).collect() +} + +#[test] +fn variables_data_sets_and_bindings_are_read_back() { + let doc = variables_doc(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.variables.variables, doc.variables.variables); + assert_eq!(back.variables.datasets, doc.variables.datasets); + assert_eq!(back.variables.active_dataset.as_deref(), Some("Second")); + assert!(back.variables.locked); + assert_eq!(bound_objects(&back), bound_objects(&doc)); + assert_eq!( + bound_objects(&back), + ["name → type \"Ann\" None", "show → path None", "show → path Some(\"Badge\")", "picture → image Some(\"/tmp/a b/one.png\") None"] + ); + assert_eq!(library_notes(&doc), Vec::::new()); + // The document read back is written the same way again. + let library = |p: String| p[p.find("%_/XMLNode :").unwrap()..p.find("(#document)").unwrap()].to_string(); + assert_eq!(library(program_shown(&back)), library(program_shown(&doc))); +} + +#[test] +fn variables_and_data_sets_past_the_readers_caps_are_left_out() { + use vectorcraft_doc::{DataSet, Variable, VariableKind}; + let mut doc = variables_doc(); + for i in 0..300 { + doc.variables.variables.push(Variable { name: format!("extra{i}"), kind: VariableKind::Text }); + } + for i in 0..1_000 { + doc.variables.datasets.push(DataSet { name: format!("Row {i}"), values: Default::default() }); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.variables.variables[..], doc.variables.variables[..256]); + assert_eq!(back.variables.datasets[..], doc.variables.datasets[..1_000]); + assert_eq!(bound_objects(&back).len(), 4); + assert_eq!(library_notes(&doc), Vec::::new()); +} + +/// Bindings that fill the reader's byte budget: the bindings past it are left out, and every +/// variable and every binding written reads back. +#[test] +fn bindings_that_fill_the_readers_budget_keep_every_variable() { + use vectorcraft_doc::{Variable, VariableKind}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut ids = vec![]; + for i in 0..170 { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(1.0, 1.0, 2.0, 2.0)), Appearance::basic(solid(GOLD), Paint::None, 0.0)); + n.name = Some(format!("o{i:0>249}")); + ids.push(n.id); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + for v in 0..120 { + let name = format!("v{v:0>254}"); + doc.variables.variables.push(Variable { name: name.clone(), kind: VariableKind::Visibility }); + for id in &ids { + doc.variables.bind(*id, &name); + } + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.variables.variables, doc.variables.variables); + let bound: usize = back.variables.bindings.values().map(|names| names.len()).sum(); + assert!(bound > 0 && bound < 120 * 170, "{bound}"); + assert_eq!(library_notes(&doc), Vec::::new()); +} + +#[test] +fn long_xml_ids_and_text_of_many_lines_are_left_out() { + use vectorcraft_doc::DataValue; + let mut doc = variables_doc(); + let badge = doc.variables.objects_of("show")[1]; + doc.node_mut(badge).unwrap().name = Some("_".repeat(60)); + let lines = "\n".repeat(150_000); + doc.variables.datasets[0].values.insert("name".into(), DataValue::Text(lines)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(bound_objects(&back), ["name → type \"Ann\" None", "show → path None", "picture → image Some(\"/tmp/a b/one.png\") None"]); + assert_eq!(back.variables.datasets[0].values.get("name"), None); + assert_eq!(back.variables.datasets[1].values.get("name"), Some(&DataValue::Text(String::new()))); + // Nothing is written that the reader leaves out. + assert_eq!(library_notes(&doc), Vec::::new()); +} + +#[test] +fn a_layer_bound_to_a_visibility_variable_is_reported_left_out() { + let mut doc = variables_doc(); + assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.iter().all(|w| w != writer::BINDINGS)); + let layer = doc.layers[0].id; + doc.variables.bind(layer, "show"); + assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::BINDINGS)); + assert_eq!(bound_objects(&read_back(&doc)).len(), 4); +} + +#[test] +fn a_binding_to_an_object_no_longer_in_the_document_is_not_reported() { + let mut doc = variables_doc(); + let gone = doc.alloc_id(); + doc.variables.bind(gone, "show"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(bound_objects(&read_back(&doc)), bound_objects(&variables_doc())); +} + +#[test] +fn a_data_set_shown_that_is_not_written_is_reported_left_out() { + for active in [" Second", "Nope"] { + let mut doc = variables_doc(); + doc.variables.active_dataset = Some(active.into()); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::ACTIVE], "{active:?}"); + assert_eq!(read_back(&doc).variables.active_dataset, None, "{active:?}"); + } +} + +#[test] +fn a_locked_library_without_variables_stays_locked() { + let mut doc = Document::new(200.0, 100.0); + doc.variables.locked = true; + assert!(read_back(&doc).variables.locked); +} + +#[test] +fn names_the_reader_does_not_read_as_they_are_are_left_out() { + use vectorcraft_doc::{DataSet, Variable, VariableKind}; + let mut doc = variables_doc(); + for name in ["x".repeat(256), " padded".into(), String::new(), "show".into()] { + doc.variables.variables.push(Variable { name: name.clone(), kind: VariableKind::Text }); + doc.variables.datasets.push(DataSet { name, values: Default::default() }); + } + doc.variables.datasets.push(DataSet { name: "First".into(), values: Default::default() }); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LIMITS), "{:?}", out.warnings); + let back = read_back(&doc); + let expected = variables_doc(); + assert_eq!(back.variables.variables, expected.variables.variables); + let mut datasets = expected.variables.datasets.clone(); + datasets.push(DataSet { name: "show".into(), values: Default::default() }); + assert_eq!(back.variables.datasets, datasets); + assert_eq!(library_notes(&doc), Vec::::new()); +} + +#[test] +fn a_document_without_variables_has_no_library() { + let p = program(&sample()); + assert!(!p.contains("XMLNode")); +} + +#[test] +fn an_unlocked_library_without_a_data_set_is_read_back() { + let mut doc = variables_doc(); + doc.variables.datasets.clear(); + doc.variables.active_dataset = None; + doc.variables.locked = false; + let back = read_back(&doc); + let expected = vectorcraft_doc::Variables { bindings: back.variables.bindings.clone(), ..(*doc.variables).clone() }; + assert_eq!(*back.variables, expected); + assert_eq!(bound_objects(&back), bound_objects(&doc)); +} + +#[test] +fn bindings_and_values_the_reader_cant_take_are_left_out() { + use vectorcraft_doc::{DataValue, VariableKind}; + let mut doc = variables_doc(); + let layer = doc.layers[0].id; + // Type of only white space is written as its outlines. + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), " ", style(18.0, Color::BLACK)); + let t = text_node(&mut doc, t); + let text_id = t.id; + doc.insert(Some(layer), usize::MAX, t).unwrap(); + doc.variables.bind(text_id, "name"); + doc.variables.variables.push(vectorcraft_doc::Variable { name: "a:b".into(), kind: VariableKind::Text }); + let first = &mut doc.variables.datasets[0].values; + first.insert("chart".into(), DataValue::Graph("a,b\n1,2".into())); + first.insert("a:b".into(), DataValue::Text("lost".into())); + first.insert("picture".into(), DataValue::Image("/a:b/pic.png".into())); + let second = &mut doc.variables.datasets[1].values; + second.insert("picture".into(), DataValue::Image("relative/pic.png".into())); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BINDINGS), "{:?}", out.warnings); + assert!(out.warnings.iter().any(|w| w == writer::VALUES), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.variables.objects_of("name").len(), 1); + assert_eq!(back.variables.datasets[0].values.get("chart"), None); + assert_eq!(back.variables.datasets[0].values.get("a:b"), None); + assert_eq!(back.variables.datasets[0].values.get("picture"), None); + assert_eq!(back.variables.datasets[1].values.get("picture"), None); + assert_eq!(bound_objects(&back).len(), 4); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 37b8868ec..5bb33aa52 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -17,7 +17,14 @@ use crate::ps; mod images; mod paints; +mod styles; +mod symbols; mod texts; +mod variables; + +#[cfg(test)] +pub(super) use styles::same_appearance; +pub(super) use styles::{GRAPHIC_STYLES, LARGE_CANVAS_STYLES, has_graphic_styles, styled_objects}; pub(super) use images::PAYLOAD; #[cfg(test)] @@ -25,21 +32,25 @@ pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; #[cfg(test)] pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; #[cfg(test)] +pub(super) use symbols::{MISSING_SYMBOL, SYMBOL_ART, SYMBOL_COUNT, SYMBOL_OPTIONS}; +#[cfg(test)] pub(super) use texts::TYPE_ATTRIBUTES; +#[cfg(test)] +pub(super) use variables::{ACTIVE, BINDINGS, LIMITS, VALUES}; /// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left /// out). Groups and layers need no limit of their own: a document is never deeper than /// [`vectorcraft_doc::MAX_DEPTH`]. const MAX_NEST: u32 = 8; -pub(super) const TYPE: &str = "type in opacity masks, pattern tiles and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; -pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps"; +pub(super) const TYPE: &str = "type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; +pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile or in a symbol, and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps"; pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps"; pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps"; pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps"; pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; pub(super) const LIVE: &str = - "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; + "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; @@ -160,8 +171,66 @@ pub(super) struct Writer<'a> { /// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the /// art being written is in. hidden: u32, + /// The image just written was written as a placed file. + placed: bool, + /// The type just written was written as type, not as its outlines. + typed: bool, + /// The objects bound to variables so far, in the order they are written: each one's id, its XML + /// id and the variables it is bound to ([`Self::bound_variables`]). + bound: Vec<(vectorcraft_doc::NodeId, String, Vec)>, /// The text document: the stories of the type written as text. pub(super) texts: texts::TextDoc, + /// Symbol instances on the layers may be written as instances ([`Self::live_instance`]); false + /// on a large canvas, where they are written as their art. + pub(super) symbols_live: bool, + /// The document symbols are defined from: on a large canvas, the document at its own size + /// (symbols' art is in its own space, which the canvas's scale doesn't change). + pub(super) symbol_doc: &'a Document, + /// A symbol's art is being written ([`Self::define_symbol`]). + in_symbol: bool, + /// The document → the space an instance's `RTransformMatrix` takes its symbol's art to: art + /// space, and in a symbol's art the canvas's turned y up about art space's origin there. + instance_space: Affine, + /// The symbol definitions written (`%AI14_BeginSymbol` … `%AI10_EndSymbol`), each after those + /// its art uses, and their names. + symbol_defs: Vec, + defined_symbols: HashSet, + /// The symbols whose definitions are being written. + defining_symbols: Vec, + /// Whether each symbol's own art paints with a pattern, and the symbols its instances use + /// ([`Self::symbol_own_patterns`]). + pattern_symbols: HashMap)>, + /// Whether each symbol's art paints with a pattern, nested symbols included + /// ([`Self::symbol_paints_pattern`]). + patterned_symbols: HashMap, + /// Pattern tiles were written in the art being written ([`Self::define_symbol`] starts it over + /// for each symbol's art). + wrote_pattern: bool, + /// The symbols whose definitions hold pattern tiles: a brushed stroke's brush art or a live + /// object's art can paint with a pattern that the symbol's own objects don't name. + pattern_written: HashSet, + /// Art that draws as it should only where it is was written in the art being written + /// ([`Self::define_symbol`] starts it over for each symbol's art): a stroke as its outline or its + /// brush's art, type as its outlines, a live object or a symbol instance as its art, or a shape + /// with rounded live corners. + wrote_fixed: bool, + /// The symbols whose definitions hold such art, nested symbols' included + /// ([`Self::keeps_scale`]). + fixed_symbols: HashSet, + /// The largest scale a live instance in the art being written applies, nested symbols' scales + /// included, and that of each symbol's definition. + inner_scale: f64, + symbol_scales: HashMap, + /// The entries a symbol's dictionary adds to its art's dictionary, and the art's id. + root_entries: Option<(vectorcraft_doc::NodeId, String)>, + /// The objects that may be written with an art style, as they were before baking, by id + /// ([`styles::styled_objects`]). + pub(super) styled: HashMap>, + /// The art styles written ([`styles`]). + styles: styles::Styles, + /// An object's drawn look is being written: no object in it is written with a style, and its + /// effects are the style's. + in_look: bool, } impl<'a> Writer<'a> { @@ -181,6 +250,9 @@ impl<'a> Writer<'a> { canvas_origin: origin, payloads: vec![], hidden: 0, + placed: false, + typed: false, + bound: vec![], texts: texts::TextDoc::default(), cms: vectorcraft_color::cms::active(), body: String::new(), @@ -195,6 +267,25 @@ impl<'a> Writer<'a> { next: HashMap::new(), brushes: None, symbol_nodes: 0, + symbols_live: true, + symbol_doc: doc, + in_symbol: false, + instance_space: to_art, + symbol_defs: vec![], + defined_symbols: HashSet::new(), + defining_symbols: vec![], + pattern_symbols: HashMap::new(), + patterned_symbols: HashMap::new(), + wrote_pattern: false, + pattern_written: HashSet::new(), + wrote_fixed: false, + fixed_symbols: HashSet::new(), + inner_scale: 1.0, + symbol_scales: HashMap::new(), + root_entries: None, + styled: HashMap::new(), + styles: styles::Styles::default(), + in_look: false, } } @@ -383,6 +474,9 @@ impl<'a> Writer<'a> { /// Warn when `n` has effects the data leaves out. fn effects(&mut self, n: &Node) { + if self.in_look { + return; + } let items = n.appearance.items.iter().filter(|i| i.visible()).flat_map(|i| i.effects()); if n.appearance.effects.iter().chain(items).any(|e| e.visible) { self.warn(EFFECTS); @@ -394,6 +488,20 @@ impl<'a> Writer<'a> { if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } + if self.in_symbol { + self.wrote_fixed |= symbols::redrawn(n); + } + // On the layers, outside art cut to a clipping object: with its art style, when it has one. + if !self.in_look + && self.hidden == 0 + && self.nest == 0 + && self.clip.is_none() + && !self.in_symbol + && let Some(orig) = self.styled.get(&n.id).cloned() + && let Some(name) = self.object_style(n, &orig) + { + return self.styled_object(n, &orig, &name); + } self.effects(n); self.state(n.locked, !n.visible); let xy = Xy::of(n); @@ -422,10 +530,18 @@ impl<'a> Writer<'a> { NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), NodeKind::Image(im) => self.image(im, xy), + NodeKind::SymbolInstance { symbol, xf } if self.live_instance(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { - self.warn(LIVE); - self.line("u"); + // The canvas places an instance inside a symbol's art where the outer instance puts + // it (9-slice guides, stains and patterns included), and this art doesn't move with it. + self.wrote_fixed |= self.in_symbol; let doc = self.doc; + if doc.symbols.iter().any(|s| &s.name == symbol) { + self.warn(symbols::SYMBOL_ART); + } else { + self.warn(symbols::MISSING_SYMBOL); + } + self.line("u"); if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { @@ -491,23 +607,42 @@ impl<'a> Writer<'a> { } } - /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]) - /// or an opacity mask (`AI9_Mask`, see [`Self::mask`]). + /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), + /// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an + /// empty `AIArtName`), has an opacity mask (`AI9_Mask`, see [`Self::mask`]), or is a symbol's + /// art (its symbol's entries first, see [`Self::define_symbol`]). fn dictionary(&mut self, n: &Node) { let name = n.name.as_deref().filter(|s| !s.is_empty()); + let placed = std::mem::take(&mut self.placed); + let typed = std::mem::take(&mut self.typed); + let bound = self.bound_variables(n, placed, typed); let mut mask = n.extras().mask.as_deref(); // Masks nested too deep are left out (an empty mask would hide the object). if mask.is_some() && self.nest >= MAX_NEST { self.warn(DEEP_MASKS); mask = None; } - if name.is_none() && mask.is_none() { + let entries = match &self.root_entries { + Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e), + _ => None, + }; + if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() { return; } self.body.push_str("%_/ArtDictionary :\n"); - if let Some(name) = name { - let id = self.take_id(name); + if let Some(e) = entries { + self.body.push_str(&e); + } + if name.is_some() || !bound.is_empty() { + let id = self.take_id(name.unwrap_or("XMLID")); let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes())); + if name.is_none() { + // The XML id names the object unless the dictionary gives it another name. + self.body.push_str("%_() /String (AIArtName) ,\n"); + } + if !bound.is_empty() { + self.bound.push((n.id, id, bound)); + } } if let Some(m) = mask { self.mask(m); @@ -751,6 +886,7 @@ impl<'a> Writer<'a> { AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { self.warn(STROKES); + self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; } @@ -812,6 +948,7 @@ impl<'a> Writer<'a> { let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; return self.path_object(path, rule, Some(&flat), None, xy); } + self.wrote_pattern = true; let outer = self.clip.clone(); let region = self.cut(path, rule); self.clip = Some(region); @@ -850,6 +987,7 @@ impl<'a> Writer<'a> { self.plain_object(®ion, FillRule::NonZero, fill, None, own); } if let Some(s) = stroke { + self.wrote_fixed = true; let outline = stroke::outline_region(path, rule, s); let region = self.cut(&outline, FillRule::NonZero); let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; @@ -913,13 +1051,13 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the - /// text document can hold it, outside opacity masks, pattern tiles and art cut to a clipping - /// object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type names the - /// story of its thread, which its first frame holds. + /// text document can hold it, outside opacity masks, pattern tiles, symbols' art and art cut to + /// a clipping object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type + /// names the story of its thread, which its first frame holds. fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; let doc = self.doc; - let placed = if self.hidden > 0 || self.clip.is_some() { + let placed = if self.hidden > 0 || self.clip.is_some() || self.in_symbol { None } else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) { // A thread is checked for what it loses, in every frame, when its story is written. @@ -931,6 +1069,7 @@ impl<'a> Writer<'a> { None }; let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) }; + self.typed = true; if lost { self.warn(texts::TYPE_ATTRIBUTES); } diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index d8bb4b16d..898c91aa7 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -13,7 +13,7 @@ use crate::ps; pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps"; pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps"; pub(in crate::ai_editing) const LINKS_EMBEDDED: &str = - "linked images in opacity masks and pattern tiles are written as embedded images in the editing data for other apps"; + "linked images in opacity masks, pattern tiles and symbols are written as embedded images in the editing data for other apps"; /// The most pixels an image is written with: as many as the reader reads. pub(super) const MAX_PIXELS: u64 = 1 << 25; @@ -94,7 +94,7 @@ impl Samples { impl Writer<'_> { /// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a /// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on - /// `%_` lines. One whose pixels can't be read is its outline filled mid gray. Where the art is + /// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is /// cut to a clipping path, the image is in a clipping group of that path. pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) { let doc = self.doc; @@ -120,7 +120,11 @@ impl Writer<'_> { let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height)); match &im.link { - Some(link) if self.hidden == 0 => self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf), + Some(link) if self.hidden == 0 && !self.in_symbol => { + // Cut to a region, the placed file is in a clipping group. + self.placed = cut.is_none(); + self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf); + } other => { if other.is_some() { self.warn(LINKS_EMBEDDED); @@ -154,9 +158,12 @@ impl Writer<'_> { /// `[matrix] 0 0 w h w h 8 type alpha reserved 1 0 4 4 0 0`, `%%BeginData:` counting `XI`, its /// line end and the color samples, `XI`, the color samples then the alpha channel in binary, /// `%%EndData`, `XH`, `%AI5_EndRaster`, `N`. The matrix is an art-space matrix applied to the - /// pixels with their rows running downwards, `(u, -v)`, as §8.1's image matrix. + /// pixels with their rows running downwards, `(u, -v)`, as §8.1's image matrix; in a symbol's + /// art (outside a pattern's tile in it), a matrix in the canvas's space turned y up about art + /// space's origin there, as an instance's matrix in it is. fn raster(&mut self, s: &Samples, xf: Affine) { - let m = matrix(self.to_art * xf * Affine::FLIP_Y); + let space = if self.in_symbol && self.defining.is_empty() { self.instance_space } else { self.to_art }; + let m = matrix(space * xf * Affine::FLIP_Y); let (w, h) = (s.width, s.height); let alpha = u8::from(s.alpha.is_some()); let mut payload = Vec::with_capacity(2 + s.color.len() + s.alpha.as_ref().map_or(0, Vec::len)); @@ -233,7 +240,7 @@ fn matrix(m: Affine) -> String { } /// `v` rounded to ten decimals, without trailing zeros (0 when not finite). -fn precise(v: f64) -> String { +pub(super) fn precise(v: f64) -> String { let r = (v * 1e10).round() / 1e10; if !r.is_finite() { return if v.is_finite() { format!("{}", v + 0.0) } else { "0".into() }; diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index d805d9d0e..b4966968e 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -103,6 +103,22 @@ impl Writer<'_> { name } + /// The gradient definitions' state, for [`Self::gradient_rollback`]: their count, which ones the + /// art uses, and the next name's number. + pub(super) fn gradient_mark(&self) -> (usize, Vec, u64) { + (self.gradients.len(), self.gradients.iter().map(|d| d.used).collect(), self.next_gradient) + } + + /// The gradient definitions as they were at `mark` ([`Self::gradient_mark`]). + pub(super) fn gradient_rollback(&mut self, mark: (usize, Vec, u64)) { + let (len, used, next) = mark; + self.gradients.truncate(len); + for (d, u) in self.gradients.iter_mut().zip(used) { + d.used = u; + } + self.next_gradient = next; + } + /// `p` as the paint of a fill or stroke whose gradient is placed on `bounds`: a gradient is /// placed there (it then no longer depends on the shape it paints, which may be cut); one the /// data can't hold is its average color, with a warning. @@ -222,24 +238,11 @@ impl Writer<'_> { /// last stop; a radial one's `… Bm` (the unit circle onto its extent ellipse). fn instance(&mut self, g: &GradientPaint, stroke: bool, clip: bool, path: &BezPath) { let Some(geom) = g.geom else { return }; - let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); self.line(if stroke { "1 Bb" } else { "Bb" }); + self.instance_head(g, clip, path); let m = self.grad_space; let u = geom.end - geom.start; let radial = g.gradient.kind == GradientKind::Radial; - let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); - if radial { - let c = m * geom.start; - let off = geom.focal.map_or(Vec2::ZERO, |f| m * f - c); - let (angle, fraction) = if off.hypot() > 1e-9 { place.focal } else { (0.0, 0.0) }; - let off = if fraction > 0.0 { off } else { Vec2::ZERO }; - ps::push_nums(&mut self.body, &[off.x, off.y, angle, fraction]); - self.line("Bh"); - } - let _ = write!(self.body, "{} {} ", u8::from(!clip), ps::bytes(name.as_bytes())); - ps::push_nums(&mut self.body, &place.bg); - ps::push_nums(&mut self.body, &place.transform.as_coeffs()); - self.line("1 Bg"); // The unit vector along the gradient, and across it as long. let frame = |from: f64, to: f64| { let p = geom.start + u * from; @@ -267,6 +270,28 @@ impl Writer<'_> { matrix(frame(last, last + CAP / len), "Bc"); } + /// A gradient instance's focal point (`Bh`, radial gradients only) and its `Bg` line, as + /// [`Self::instance`] writes them; also what a fill's art style holds after its `Bb`. + pub(super) fn instance_head(&mut self, g: &GradientPaint, clip: bool, path: &BezPath) { + let Some(geom) = g.geom else { return }; + let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); + let m = self.grad_space; + let radial = g.gradient.kind == GradientKind::Radial; + let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); + if radial { + let c = m * geom.start; + let off = geom.focal.map_or(Vec2::ZERO, |f| m * f - c); + let (angle, fraction) = if off.hypot() > 1e-9 { place.focal } else { (0.0, 0.0) }; + let off = if fraction > 0.0 { off } else { Vec2::ZERO }; + ps::push_nums(&mut self.body, &[off.x, off.y, angle, fraction]); + self.line("Bh"); + } + let _ = write!(self.body, "{} {} ", u8::from(!clip), ps::bytes(name.as_bytes())); + ps::push_nums(&mut self.body, &place.bg); + ps::push_nums(&mut self.body, &place.transform.as_coeffs()); + self.line("1 Bg"); + } + /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd` (type 0 linear, /// 1 radial), `[`, each stop on a `%_` line (`color style opacity 6 midpoint rampPoint Bs`), a /// linear gradient's from the last to the first and a radial one's from the first to the last, @@ -341,9 +366,10 @@ impl Writer<'_> { /// Can pattern `name` be written as a definition and painted with from here: the document has /// it, its tiles make a grid, its art is within its tile and holds something, and it isn't used - /// in another pattern's tile or nested more than [`MAX_NEST`] deep? + /// in another pattern's tile or a symbol's art (the reader keeps no symbol whose art paints with + /// a pattern) or nested more than [`MAX_NEST`] deep? pub(super) fn pattern_writable(&self, name: &str) -> bool { - if !self.defining.is_empty() || self.nest >= MAX_NEST { + if !self.defining.is_empty() || self.nest >= MAX_NEST || self.in_symbol { return false; } if self.patterns.iter().any(|p| p.name == name) { @@ -389,9 +415,12 @@ impl Writer<'_> { self.line("n\n%_/ArtDictionary :\n%_1 /Bool (AIPattern_Editor_Backing_Tile_Rect) ,\n%_;\n%_"); self.nest += 1; self.hidden += 1; + // The tile's effects are its own, not those of a drawn look that first uses it. + let in_look = std::mem::take(&mut self.in_look); for a in &def.art { self.node(a); } + self.in_look = in_look; self.hidden -= 1; self.nest -= 1; let art; diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs new file mode 100644 index 000000000..ace7e6b0f --- /dev/null +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -0,0 +1,684 @@ +//! Art styles: objects with live effects a plain path can't hold, written as their drawn look, the +//! object itself on `%_` lines and `1 (style) XW`, and the styles they name in +//! `%AI9_BeginArtStyles` (see [`crate::ai_editing`] for the form). Each style is read back before it +//! is written ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of +//! a graphic style, with the object's own gradient): one that reads back differently is not +//! written, and the object is written as its drawn look alone. + +use std::collections::HashMap; +use std::fmt::Write; +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{BezPath, FillRule, Rect, Shape}; + +use super::{Writer, Xy, blend_code, compound_path, hide}; +use crate::ai_ops; +use crate::ps; + +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of the editing data for other apps"; +pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps"; + +/// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity +/// masks or symbols linked to a graphic style? +pub(in crate::ai_editing) fn has_graphic_styles(doc: &Document) -> bool { + fn linked(n: &Node) -> bool { + n.extras().graphic_style.is_some() + || n.extras().mask.as_ref().is_some_and(|m| linked(&m.art)) + || n.children().is_some_and(|c| c.iter().any(|m| linked(m))) + } + doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles || doc.layers.iter().any(|l| linked(l)) || doc.symbols.iter().any(|s| linked(&s.art)) +} + +/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), +/// by id: paths and compound paths with points and groups, with live effects, without Opacity & +/// Mask Define Knockout Shape (the reader takes an object's transparency from its look, which +/// doesn't carry it). A group's members are paths, compound paths or such groups, none with effects +/// or anything baking changes. +pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { + fn walk(nodes: &[Arc], out: &mut HashMap>) { + for n in nodes { + if candidate(n) { + out.insert(n.id, n.clone()); + continue; + } + // Baking a container with effects of its own changes its members, which keep their ids: + // they are written as baking left them. + if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &n.kind + && !bakes_members(n) + { + walk(children, out); + } + } + } + let mut out = HashMap::new(); + walk(&doc.layers, &mut out); + out +} + +/// Does container `n` need baking for what it has itself, whatever its members hold? +fn bakes_members(n: &Node) -> bool { + let mut own = n.clone(); + if let Some(children) = own.children_mut() { + children.clear(); + } + vectorcraft_effects::needs_bake(&own) +} + +/// Whether `n` may be written with an art style ([`styled_objects`]). +fn candidate(n: &Node) -> bool { + if n.appearance.effects.is_empty() || n.knockout_shape { + return false; + } + match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => has_points(path), + NodeKind::Compound { .. } => has_points(&compound_path(n)), + NodeKind::Group { children, clip: false } => !n.is_compound_shape() && children.iter().all(|c| plain_member(c)), + _ => false, + } +} + +/// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)? +fn has_points(path: &vectorcraft_geom::PathData) -> bool { + path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) +} + +/// A member of a group written with an art style: written as it is, and read back as it is. +fn plain_member(n: &Node) -> bool { + let quiet = n.appearance.effects.is_empty() && n.appearance.items.iter().all(|i| i.effects().is_empty()) && !vectorcraft_effects::needs_bake(n); + quiet + && match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => true, + NodeKind::Group { children, .. } => children.iter().all(|c| plain_member(c)), + _ => false, + } +} + +/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`). +#[derive(Default)] +pub(in crate::ai_editing) struct Styles { + /// Every style, in the order written: its name and text. + known: Vec<(String, String)>, + /// The name of each style by its text. + by_text: HashMap, + next_anon: u64, +} + +impl Styles { + /// The name of an object style of `text`: a style with the same text already written, else a + /// new one named `Anon` and a number. + fn object_style(&mut self, text: String) -> String { + if let Some(name) = self.by_text.get(&text) { + return name.clone(); + } + self.next_anon += 1; + let name = format!("Anon {}", self.next_anon); + self.by_text.insert(text.clone(), name.clone()); + self.known.push((name.clone(), text)); + name + } +} + +/// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text. +fn known_style(name: &str, text: &str) -> String { + format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes())) +} + +/// The number transparency's `Xy` gives the blending mode a live effect's `mode` names (any case, +/// spaces and dashes ignored). +fn mode_code(mode: &str) -> Option { + let key: String = mode.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); + BlendMode::ALL.iter().find(|m| format!("{m:?}").to_ascii_lowercase() == key).map(|m| blend_code(*m)) +} + +/// A filter's `Xy` line: `mode opacity isolate knockout shape Xy`. +fn xy_line(s: &mut String, xy: Xy) { + let knockout = match xy.knockout { + Knockout::Off => 0, + Knockout::On => 1, + Knockout::Neutral => 2, + }; + let _ = write!(s, "{} ", blend_code(xy.blend)); + ps::push_num(s, f64::from(xy.opacity)); + let _ = writeln!(s, " {} {knockout} {} Xy", u8::from(xy.isolate), u8::from(xy.shape)); +} + +/// The line style of a paint style that isn't a stroke's. +const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n"; + +/// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the +/// style's own filters), `/Visible` and the dictionary's start. +fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { + let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible)); +} + +/// The filter that stands for the object's contents in its stack. +const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n"; + +/// A basic filter's end. +const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; + +/// The blend filter of transparency `xy`. +fn blend_filter(s: &mut String, xy: Xy) { + basic(s, "Blend Style Filter", false, true); + s.push_str("/Dictionary : /NotRecorded ,\n/BlendStyle : "); + xy_line(s, xy); + s.push_str(NO_LINE); + s.push_str("0 XR\n/Def ; \n (BlendStyle) ,\n"); + s.push_str(END_BASIC); +} + +/// A live effect's dictionary entries. +#[derive(Default)] +struct Entries(String); + +impl Entries { + fn real(&mut self, key: &str, v: f64) { + ps::push_num(&mut self.0, v); + let _ = writeln!(self.0, " /Real {} ,", ps::bytes(key.as_bytes())); + } + + fn int(&mut self, key: &str, v: i64) { + let _ = writeln!(self.0, "{v} /Int {} ,", ps::bytes(key.as_bytes())); + } + + fn bool(&mut self, key: &str, v: bool) { + let _ = writeln!(self.0, "{} /Bool {} ,", u8::from(v), ps::bytes(key.as_bytes())); + } +} + +/// `p`'s entry `key` as a number. +fn num(p: &Value, key: &str) -> Option { + p.get(key)?.as_f64().filter(|v| v.is_finite()) +} + +/// `p`'s entry `key` as text, for comparing: lower case, letters and digits only. +fn word(p: &Value, key: &str) -> Option { + Some(p.get(key)?.as_str()?.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase()) +} + +/// `p`'s entry `key` as a switch. +fn flag(p: &Value, key: &str) -> Option { + p.get(key)?.as_bool() +} + +impl Writer<'_> { + /// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string. + fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { + let saved = std::mem::take(&mut self.body); + self.color(paint, stroke); + std::mem::replace(&mut self.body, saved) + } + + /// A fill style holding a color, as an effect's entry `key` or a fill's own. + fn fill_style(&mut self, s: &mut String, color: vectorcraft_color::Color, key: &str) { + let ops = self.color_ops(&Paint::solid(color), false); + let _ = write!(s, "/FillStyle : 0 O\n{ops}0 1 0 0 0 Xy\n{NO_LINE}0 XR\n0 1 Xd\n/Def ; \n {} ,\n", ps::bytes(key.as_bytes())); + } + + /// A fill's basic filter: a color, or a gradient placed on the object's outline (`on`: the + /// object's own outline and rule), filling by the non-zero rule. `None` for other paint. + fn fill_filter(&mut self, s: &mut String, f: &FillLayer, visible: bool, on: Option<(&BezPath, FillRule)>) -> Option<()> { + if f.overprint { + return None; + } + // A color comes before the paint state, a gradient's instance after it. + let (color, gradient) = match &f.paint { + Paint::Solid { .. } => (self.color_ops(&f.paint, false), String::new()), + Paint::Gradient(_) => { + let (path, _) = on?; + let Paint::Gradient(placed) = self.placed(&f.paint, path.bounding_box()) else { return None }; + (String::new(), format!("Bb\n{}0 BB\n", self.gradient_bg(&placed, path)?)) + } + Paint::Pattern { .. } | Paint::None => return None, + }; + basic(s, "Fill Style Filter", false, visible); + let _ = write!( + s, + "/Dictionary : /NotRecorded ,\n0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{color}0 1 0 0 0 Xy\n{NO_LINE}0 XR\n0 1 Xd\n{gradient}" + ); + s.push_str("/Def ; \n (FillStyle) ,\n"); + s.push_str(END_BASIC); + Some(()) + } + + /// A stroke's basic filter: a color, its line style, and its width profile as the arrays + /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for + /// other paint. + fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { + return None; + } + let ops = self.color_ops(&st.paint, true); + basic(s, "Stroke Style Filter", false, visible); + s.push_str("2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n"); + s.push_str(&ops); + s.push_str("0 1 0 0 0 Xy\n"); + ai_ops::line_style(s, st); + s.push_str("0 XR\n1 0 Xd\n/Def ; \n (StrokeStyle) ,\n"); + if let Some(p) = &st.profile { + let array = |s: &mut String, key: &str, values: &mut dyn Iterator| { + s.push_str("/Array :"); + for v in values { + s.push(' '); + ps::push_num(s, v); + s.push_str(" /Real ,"); + } + let _ = writeln!(s, " ; {} ,", ps::bytes(key.as_bytes())); + }; + array(s, "BSVariableWidthTValuesArray", &mut p.points.iter().map(|q| q.0)); + array(s, "BSLeftWidthsArray", &mut p.points.iter().map(|q| q.1)); + array(s, "BSRightWidthsArray", &mut p.points.iter().map(|q| q.2)); + } + s.push_str(END_BASIC); + Some(()) + } + + /// A gradient instance's focal point (`Bh`, radial gradients only) and its `flag (name) x y + /// angle length a b c d tx ty 1 Bg` line for placed gradient `g` on `path`, as a fill's style + /// holds them ([`Self::instance_head`]). + fn gradient_bg(&mut self, g: &vectorcraft_color::GradientPaint, path: &BezPath) -> Option { + let saved = std::mem::take(&mut self.body); + self.instance_head(g, false, path); + let line = std::mem::replace(&mut self.body, saved); + (!line.is_empty()).then_some(line) + } + + /// An appearance item's basic filter. `None` for an item with transparency or effects of its + /// own, which the reader doesn't read on an object. + fn item_filter(&mut self, s: &mut String, item: &AppearanceItem, on: Option<(&BezPath, FillRule)>) -> Option<()> { + if item.opacity() < 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() { + return None; + } + match item { + AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on), + AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()), + } + } + + /// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain + /// of its stack (the fills and strokes that paint, the top one first, the contents among them + /// where the appearance puts them), its effects and its blend filter. A gradient fill is + /// placed on the outline of `on`, and a fill fills by its rule (`None`: the non-zero rule, on no + /// object). `None` when a fill, stroke or effect isn't one written here. + pub(super) fn style_text(&mut self, ap: &Appearance, xy: Xy, on: Option<(&BezPath, FillRule)>) -> Option { + let mut s = String::from( + "/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n", + ); + let ci = ap.contents_index.unwrap_or(0).min(ap.items.len()); + let painting: Vec<(usize, &AppearanceItem)> = ap.items.iter().enumerate().filter(|(_, i)| !i.paint().is_none()).collect(); + let below = painting.iter().filter(|(at, _)| *at < ci).count(); + // Listed top first: the contents go above the `below` fills and strokes under them. + for (written, (_, item)) in painting.iter().rev().enumerate() { + if painting.len() - written == below { + s.push_str(CONDUIT); + } + self.item_filter(&mut s, item, on)?; + } + if below == 0 { + s.push_str(CONDUIT); + } + s.push_str(";\n /Part ,\n"); + for e in &ap.effects { + s.push_str(&self.effect_filter(e)?); + } + blend_filter(&mut s, xy); + s.push_str(";\n\n/Execution ;\n /Def ;\n"); + Some(s) + } + + /// A live effect's basic filter, with the settings the reader reads + /// ([`crate::import`]'s `.ai` reader, `style.rs`), in the units it reads them in. `None` for an + /// effect, or a setting, that isn't written here. + fn effect_filter(&mut self, e: &Effect) -> Option { + let p = vectorcraft_effects::merged_params(&e.id, &e.params); + let mut d = Entries::default(); + let id = e.id.as_str(); + let name = if id.starts_with("stylize.") { self.stylize_entries(id, &p, &mut d)? } else { Self::path_entries(id, &p, &mut d)? }; + let mut s = String::new(); + basic(&mut s, name, true, e.visible); + s.push_str("/Dictionary : /NotRecorded ,\n"); + s.push_str(&d.0); + s.push_str(END_BASIC); + Some(s) + } + + /// A Stylize effect's entries ([`Self::effect_filter`]) and its filter's name. + fn stylize_entries(&mut self, id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + "stylize.dropShadow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + let darkness = word(p, "shade").as_deref() == Some("darkness"); + if darkness { + d.real("dark", num(p, "darkness")?); + } + d.real("vert", num(p, "y")?); + d.real("horz", num(p, "x")?); + d.real("blur", num(p, "blur")? / 2.0); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::BLACK); + self.fill_style(&mut d.0, color, "sclr"); + d.int("csrc", i64::from(darkness)); + "Adobe Drop Shadow" // brand-ok: the filter's name in the files + } + "stylize.innerGlow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + d.real("blur", num(p, "blur")?); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::WHITE); + self.fill_style(&mut d.0, color, "gclr"); + d.int("gtyp", i64::from(word(p, "source").as_deref() == Some("edge"))); + "Adobe Inner Glow" // brand-ok: the filter's name in the files + } + "stylize.outerGlow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + d.real("blur", num(p, "blur")? / 2.0); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::BLACK); + self.fill_style(&mut d.0, color, "sclr"); + "Adobe Outer Glow" // brand-ok: the filter's name in the files + } + "stylize.feather" => { + d.real("Radius", num(p, "radius")?); + "Adobe Fuzzy Mask" // brand-ok: the filter's name in the files + } + "stylize.roundCorners" => { + d.real("radius", num(p, "radius")?); + "Adobe Round Corners" // brand-ok: the filter's name in the files + } + "stylize.scribble" => { + d.real("Angle", num(p, "angle")?); + d.real("EdgeOverlap", num(p, "overlap")?); + d.real("EdgeOverlapVariation", num(p, "overlapVariation")?); + d.real("StrokeWidth", num(p, "strokeWidth")?); + d.real("Scribbliness", num(p, "curviness")? / 100.0); + d.real("ScribbleVariation", num(p, "curvinessVariation")? / 100.0); + d.real("Spacing", num(p, "spacing")?); + d.real("SpacingVariation", num(p, "spacingVariation")?); + "Adobe Scribble Fill" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// Convert to Shape's, a Path effect's or Pathfinder › Add's entries ([`Self::effect_filter`]) + /// and its filter's name. + fn path_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => { + let shape = match id { + "convertToShape.rectangle" => 0, + "convertToShape.roundedRectangle" => 1, + _ => 2, + }; + let relative = flag(p, "relative")?; + d.int("Shape", shape); + d.real("Absolute", if relative { 0.0 } else { 1.0 }); + d.real("AbsWidth", num(p, "width")?); + d.real("AbsHeight", num(p, "height")?); + d.real("RelWidth", num(p, "extraW")? / 2.0); + d.real("RelHeight", num(p, "extraH")? / 2.0); + if shape == 1 { + d.real("CornerRadius", num(p, "radius")?); + } + "Adobe Shape Effects" // brand-ok: the filter's name in the files + } + "path.offsetPath" => { + d.real("ofst", num(p, "offset")?); + let joins = match word(p, "joins")?.as_str() { + "round" => 0, + "bevel" => 1, + "miter" => 2, + _ => return None, + }; + d.int("jntp", joins); + d.real("mlim", num(p, "miterLimit")?); + "Adobe Offset Path" // brand-ok: the filter's name in the files + } + "path.outlineObject" => "Adobe Outline Type", // brand-ok: the filter's name in the files + "path.outlineStroke" => "Adobe Outline Stroke", // brand-ok: the filter's name in the files + "pathfinder.add" => { + d.int("Command", 0); + d.bool("ConvertCustom", true); + d.bool("ExtractUnpainted", true); + d.real("Mix", 0.5); + d.real("Precision", 10.0); + d.bool("RemovePoints", false); + d.real("TrapAspect", 1.0); + d.bool("TrapConvertCustom", false); + d.real("TrapMaxTint", 1.0); + d.bool("TrapReverse", false); + d.real("TrapThickness", 0.25); + d.real("TrapTint", 0.4); + d.real("TrapTintTolerance", 0.05); + "Adobe Pathfinder" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// The style object `orig` (`n` before baking) is written with; `None` when the reader wouldn't + /// read the object as it is with it. A gradient fill is placed on `n`'s outline, as the object + /// draws it. A style that isn't written leaves no gradient definition or warning behind. + pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option { + if let NodeKind::Group { children, .. } = &orig.kind + && bakes_members(orig) + && !children.iter().all(|c| self.member_keeps_outline(c)) + { + return None; + } + let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); + let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); + let path = drawn_outline(n, orig); + let rule = object_rule(orig); + let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t)); + let Some(text) = text else { + if let Some(mark) = saved.0 { + self.gradient_rollback(mark); + } + self.warnings.truncate(saved.1); + return None; + }; + Some(self.styles.object_style(text)) + } + + /// Is member `m` of a styled group whose effects change its members' outlines (and each of its + /// own members) written on the outline those effects draw on: every fill and stroke as the + /// object paints it ([`Self::direct`], [`Self::direct_stroke`]), not as filled outlines, brush + /// art or pattern tiles, and no gradient that fits its object, which the effects would fit to + /// the new outline? + fn member_keeps_outline(&self, m: &Node) -> bool { + let items_keep = m.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => f.paint.is_none() || self.direct(&f.paint), + AppearanceItem::Stroke(s) => s.paint.is_none() || self.direct_stroke(s), + }); + items_keep && m.children().is_none_or(|c| c.iter().all(|c| self.member_keeps_outline(c))) + } + + /// Object `n` (as baking left it; `orig` before baking) with the style `name`: its lock and + /// hidden state; its drawn look (a group of its vector art as baking left it, without shadows, + /// glows or feathers, the object's transparency + /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or + /// compound path's points painted with nothing, or with its gradient fill when its style has + /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. + pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { + self.state(orig.locked, !orig.visible); + let xy = Xy::of(orig); + let was = self.in_look; + self.in_look = true; + self.line("u"); + let mut look = n.clone(); + (look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) = + (None, 1.0, BlendMode::Normal, false, Knockout::Neutral, false); + if look.extras().mask.is_some() { + look.extras_mut().mask = None; + } + match &look.kind { + NodeKind::Group { children, clip: false } => { + for c in children.clone() { + self.node(&c); + } + } + _ => self.node(&look), + } + self.line("U"); + self.write_xy(xy); + self.line("0 0 Xd\n6 () XW"); + self.in_look = was; + // The object, on `%_` lines, in its own state (the look's members may have changed it). + self.state(orig.locked, !orig.visible); + let saved = std::mem::take(&mut self.body); + self.hidden += 1; + let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); + match &orig.kind { + NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), + NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), + NodeKind::Group { children, .. } => { + self.line("u"); + for c in children { + self.node(c); + } + self.line("U"); + } + _ => {} + } + self.dictionary(orig); + self.hidden -= 1; + let part = std::mem::replace(&mut self.body, saved); + hide(&part, &mut self.body); + self.block_end = usize::MAX; + let _ = writeln!(self.body, "1 {} XW", ps::bytes(name.as_bytes())); + } + + /// A styled path's or compound path's points: painted with nothing, or with its gradient fill + /// (which its style's gradient fill is), placed on `fit` (the outline's bounds when `None`). + fn styled_outline(&mut self, orig: &Node, path: &vectorcraft_geom::PathData, rule: vectorcraft_geom::FillRule, fit: Option, xy: Xy) { + let bounds = fit.unwrap_or_else(|| path.to_bezpath().bounding_box()); + let gradient = orig.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) if matches!(f.paint, Paint::Gradient(_)) => Some(f), + _ => None, + }); + match gradient { + Some(f) => { + let fill = FillLayer { paint: self.placed(&f.paint, bounds), ..f.clone() }; + self.plain_object(path, rule, Some(&fill), None, xy.object()); + } + None => self.plain_object(path, rule, None, None, xy.object()), + } + } + + /// The art styles written, in a non-printing block: `%AI9_BeginArtStyles`, each style, + /// `%AI9_EndArtStyles`. + pub(in crate::ai_editing) fn art_styles(&self, s: &mut String) { + if self.styles.known.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n"); + for (name, text) in &self.styles.known { + s.push_str(&known_style(name, text)); + } + s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n"); + } +} + +/// The outline a path's or compound path's style places a gradient fill on. +fn object_outline(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, .. } => Some(path.to_bezpath()), + NodeKind::Compound { .. } => Some(compound_path(n).to_bezpath()), + _ => None, + } +} + +/// The outline a styled path or compound path draws: `n`'s, as baking left it, when it is still a +/// path or compound path, else `orig`'s. +fn drawn_outline(n: &Node, orig: &Node) -> Option { + object_outline(orig)?; + object_outline(n).or_else(|| object_outline(orig)) +} + +/// The fill rule of a path or compound path (a group's is the default). +fn object_rule(n: &Node) -> FillRule { + match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + } +} + +/// Does the reader read object `n` written with style text `text` as it is: a path or compound +/// path with a style it reads on any object, filled by the non-zero rule when the style fills it, +/// or a group with no fills or strokes of its own? +fn object_reads(n: &Node, text: &str) -> bool { + let section = known_style("x", text); + let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_))); + let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false }; + let group = matches!(n.kind, NodeKind::Group { .. }); + if !read.simple || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) { + return false; + } + if !group { + let filled = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)) && !i.paint().is_none()); + if filled && object_rule(n) == FillRule::EvenOdd { + return false; + } + // A path's contents are nothing: the reader keeps the object's own. + read.appearance.contents_index = n.appearance.contents_index; + } + same_appearance(&read.appearance, &n.appearance) +} + +/// Do appearances `a` and `b` draw the same, as the reader reads one written from the other: the +/// fills and strokes that paint, within the written numbers' precision, and the effects with +/// their settings filled in from their defaults (blending modes in any case, colors as colors). +pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) -> bool { + let norm = |ap: &Appearance| -> Value { + let mut ap = ap.clone(); + let ci = ap.contents_index.unwrap_or(0).min(ap.items.len()); + let below = ap.items.iter().take(ci).filter(|i| !i.paint().is_none()).count(); + ap.items.retain(|i| !i.paint().is_none()); + ap.contents_index = (below > 0).then_some(below); + for e in ap.effects.iter_mut().chain(ap.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) { + e.params = normalized_params(e); + } + serde_json::to_value(&ap).unwrap_or(Value::Null) + }; + near(&norm(a), &norm(b)) +} + +/// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as +/// `#rrggbb`. +fn normalized_params(e: &Effect) -> Value { + let mut p = vectorcraft_effects::merged_params(&e.id, &e.params); + if let Some(m) = word(&p, "mode") { + p["mode"] = Value::String(m); + } + if p.get("color").is_some() { + p["color"] = Value::String(vectorcraft_effects::color_param(&p, vectorcraft_color::Color::BLACK).to_hex()); + } + for key in ["shade", "source", "joins", "points", "orientation"] { + if let Some(w) = word(&p, key) { + p[key] = Value::String(w); + } + } + p +} + +/// How far apart two numbers may read in [`near`]: a number written to 4 decimals, then converted +/// from a fraction to a percentage (`5e-3`) or from radians to degrees (`3e-3`). +const NEAR: f64 = 6e-3; + +/// JSON values equal but for numbers within the written numbers' precision. +fn near(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() <= NEAR, + _ => x == y, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(x, y)| near(x, y)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| near(v, w))), + _ => a == b, + } +} diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs new file mode 100644 index 000000000..ccec95d42 --- /dev/null +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -0,0 +1,339 @@ +//! Symbols: their definitions, the symbol list and their instances (see [`crate::ai_editing`] for +//! the form). + +use std::collections::HashSet; +use std::fmt::Write; + +use vectorcraft_color::Paint; +use vectorcraft_doc::{Knockout, Node, NodeKind, SymbolExportType}; +use vectorcraft_geom::Affine; + +use super::{MAX_NEST, Writer, Xy}; +use crate::import::MAX_SYMBOLS; +use crate::ps; + +pub(in crate::ai_editing) const SYMBOL_ART: &str = "symbol instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art, instances that scale their symbol more than 100 times, instances on the layers of a large canvas and instances of symbols past 4,096 are written as their art in the editing data for other apps"; +pub(in crate::ai_editing) const SYMBOL_OPTIONS: &str = + "the 9-slice guides, export types and dynamic type of symbols are left out of the editing data for other apps"; +pub(in crate::ai_editing) const MISSING_SYMBOL: &str = + "symbol instances of symbols the document doesn't have are written as empty groups in the editing data for other apps"; +/// The most an instance may scale its symbol's art, nested instances' scales included: the art's +/// numbers are written to four decimals, so a scale of 100 places them within 0.005 pt. +const MAX_SCALE: f64 = 100.0; +pub(in crate::ai_editing) const SYMBOL_COUNT: &str = "symbols past 4,096 are left out of the editing data for other apps"; + +/// Whether the canvas works `n` itself (not its children) out again after placing an instance: +/// type, live objects and shapes with rounded live corners, whose strokes, steps and corners depend +/// on where the instance puts them. +pub(super) fn redrawn(n: &Node) -> bool { + let rounded = |l: &vectorcraft_doc::LiveShape| match l { + vectorcraft_doc::LiveShape::Rectangle { radii, .. } => radii.iter().any(|r| *r > 0.0), + vectorcraft_doc::LiveShape::Polygon { radii, .. } | vectorcraft_doc::LiveShape::Path { radii, .. } => radii.iter().any(|r| *r > 0.0), + vectorcraft_doc::LiveShape::Ellipse { .. } | vectorcraft_doc::LiveShape::Line { .. } => false, + }; + match &n.kind { + NodeKind::Path { live: Some(l), .. } => rounded(l), + NodeKind::Text(_) + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => true, + _ => false, + } +} + +impl Writer<'_> { + /// Whether instance `n` of symbol `symbol`, placed by `xf`, is written as an instance: not on the + /// layers of a large canvas, in a pattern's tile or in art cut to a clipping object, without a + /// fill or appearance changes of its own (which stain or change the art it draws) and without + /// isolation, knockout or knockout shape of its own (which an instance's transparency doesn't + /// hold), of a symbol without 9-slice guides and without pattern paint in its art (the canvas + /// keeps an instance's pattern tiles where the document puts them, and a definition's art moves + /// with each instance) whose definition can be written ([`Self::define_symbol`]), by a matrix + /// that keeps the art's area as it is written and scales it as the definition allows + /// ([`Self::keeps_scale`]). + pub(super) fn live_instance(&mut self, n: &Node, symbol: &str, xf: Affine) -> bool { + let own = vectorcraft_brush::stain(n).is_some() || !n.extras().symbol_overrides.is_empty(); + let group_state = n.isolate || n.knockout != Knockout::Neutral || n.knockout_shape; + // The matrix as written (to ten decimals) must still keep the art's area. + let m = Affine::new((self.instance_space * xf).as_coeffs().map(|v| (v * 1e10).round() / 1e10)); + (self.symbols_live || self.in_symbol) + && self.defining.is_empty() + && self.clip.is_none() + && !own + && !group_state + && self.doc.symbol_nine_slice(symbol).is_none() + && m.as_coeffs().iter().all(|v| v.is_finite()) + && m.determinant().abs() >= 1e-12 + && self.define_symbol(symbol) + && !self.symbol_paints_pattern(symbol) + && self.keeps_scale(symbol, xf) + } + + /// Whether instance matrix `xf` places symbol `symbol`'s definition as the canvas draws the + /// instance. A definition holding art the canvas works out again after placing an instance + /// ([`redrawn`], strokes written as outlines or brush art, and instances written as their art) + /// takes only matrices that move it; + /// any other takes a scale up to [`MAX_SCALE`], nested instances' scales included. A live + /// instance in a symbol's art passes both to that symbol. + fn keeps_scale(&mut self, symbol: &str, xf: Affine) -> bool { + let [a, b, c, d, ..] = xf.as_coeffs(); + let fixed = self.fixed_symbols.contains(symbol); + let scale = a.hypot(b).max(c.hypot(d)) * self.symbol_scales.get(symbol).copied().unwrap_or(1.0); + let keeps = if fixed { (a - 1.0).abs() < 1e-9 && b.abs() < 1e-9 && c.abs() < 1e-9 && (d - 1.0).abs() < 1e-9 } else { scale <= MAX_SCALE }; + if keeps && self.in_symbol { + self.wrote_fixed |= fixed; + self.inner_scale = self.inner_scale.max(scale); + } + keeps + } + + /// Whether the art of symbol `name`, an opacity mask in it or the art of a symbol it holds an + /// instance of, at any depth, fills or strokes something (type included) with a pattern, or its + /// definition holds pattern tiles. Worked out once a symbol, after it is defined. + fn symbol_paints_pattern(&mut self, name: &str) -> bool { + if self.pattern_written.contains(name) { + return true; + } + if let Some(known) = self.patterned_symbols.get(name) { + return *known; + } + let mut seen = HashSet::from([name.to_string()]); + let mut todo = vec![name.to_string()]; + let mut found = false; + while let Some(next) = todo.pop() { + let (own, used) = self.symbol_own_patterns(&next); + if own { + found = true; + break; + } + for u in used { + if seen.insert(u.clone()) { + todo.push(u); + } + } + } + self.patterned_symbols.insert(name.to_string(), found); + found + } + + /// Whether the art of symbol `name` or an opacity mask in it fills or strokes something (type + /// included) with a pattern, and the symbols its instances and inline graphics there use; looked + /// at once a symbol. + fn symbol_own_patterns(&mut self, name: &str) -> (bool, Vec) { + if let Some(known) = self.pattern_symbols.get(name) { + return known.clone(); + } + let doc = self.symbol_doc; + let mut own = false; + let mut used = vec![]; + if let Some(sym) = doc.symbols.iter().find(|s| s.name == name) { + let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { + own |= vectorcraft_doc::pattern::uses_pattern(m, None); + match &m.kind { + NodeKind::Text(t) => { + own |= t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke)); + // Inline graphics are written as their symbols' art. + used.extend(t.runs.iter().filter_map(|r| r.inline.as_ref()).map(|art| art.symbol.clone())); + } + NodeKind::SymbolInstance { symbol, .. } => used.push(symbol.clone()), + _ => {} + } + }); + } + self.pattern_symbols.insert(name.to_string(), (own, used.clone())); + (own, used) + } + + /// An instance of `symbol` placed by `xf`, of transparency `xy`: `/SymbolInstance :`, `(name) + /// /SymbolRef ,`, the matrix taking the symbol's art to art space (in a symbol's art, to the + /// canvas's space turned y up about art space's origin there) as `a b c d tx ty + /// /RTransformMatrix` (to ten decimals), `;`, then its state block as a group's: `Xy`, `0 0 Xd` + /// and `6 () XW` when it has transparency, else `9 () XW`. Its dictionary goes between the two. + pub(super) fn symbol_instance(&mut self, symbol: &str, xf: Affine, xy: Xy) { + let m = self.instance_space * xf; + let _ = write!(self.body, "/SymbolInstance :\n{} /SymbolRef ,\n", ps::bytes(symbol.as_bytes())); + for v in m.as_coeffs() { + self.body.push_str(&super::images::precise(v)); + self.body.push(' '); + } + self.body.push_str("/RTransformMatrix\n;\n"); + // An instance with isolation, knockout or knockout shape of its own is written as its art + // ([`Self::live_instance`]); these are the values of the others. + let xy = Xy { isolate: false, knockout: Knockout::Neutral, shape: false, ..xy }; + self.block_start = self.body.len(); + if xy == Xy::PLAIN { + self.line("9 () XW"); + } else { + self.write_xy(xy); + self.line("0 0 Xd\n6 () XW"); + } + self.block_end = self.body.len(); + } + + /// Write symbol `name`'s definition, once: `%AI14_BeginSymbol`, `(name)`, `0 A`, `0 Xw`, `0 Ae`, + /// the art in the symbol's space (y down: its numbers are the symbol's own) as a group (`u` … + /// `U`; art that isn't a group, or is a plain group of one object, in a group of its own), the + /// symbol's registration point (`AI13PatternRegistrationType`, 1 to 9 from the top left, row by + /// row) in that group's dictionary, `9 () XW`, `%AI10_EndSymbol`. Linked images in the + /// art are embedded. The symbols its art's instances use are defined first, and the art is + /// written from the top of the nesting whatever the instance that asks for it, so a definition + /// is the same wherever its first instance lies. False when it can't be: the document doesn't + /// have the symbol, it is being written (its art holds an instance of itself), it is reached + /// through more than [`MAX_NEST`] definitions being written, or the definitions written and + /// being written leave no place under [`MAX_SYMBOLS`]. + pub(super) fn define_symbol(&mut self, name: &str) -> bool { + if self.defined_symbols.contains(name) { + return true; + } + let doc = self.symbol_doc; + let Some(sym) = doc.symbols.iter().find(|s| s.name == name) else { return false }; + if self.defining_symbols.iter().any(|d| d == name) || self.defining_symbols.len() >= MAX_NEST as usize { + return false; + } + // The reader keeps no more definitions than this, and each definition being written takes + // one when it ends. + if self.symbol_defs.len() + self.defining_symbols.len() >= MAX_SYMBOLS { + self.warn(SYMBOL_COUNT); + return false; + } + if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() { + self.warn(SYMBOL_OPTIONS); + } + self.defining_symbols.push(name.to_string()); + let mut used = vec![]; + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { + if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { + used.push(symbol.as_str()); + } + }); + for u in used { + self.define_symbol(u); + } + if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS { + self.defining_symbols.pop(); + self.warn(SYMBOL_COUNT); + return false; + } + // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the + // document at its own size. + let origin = self.canvas_origin; + let about_origin = Affine::new([1.0, 0.0, 0.0, -1.0, -origin.0, origin.1]); + let page_doc = std::mem::replace(&mut self.doc, doc); + let saved = ( + std::mem::take(&mut self.body), + (self.to_art, self.grad_space, self.canvas, self.instance_space), + self.state.replace((false, false)), + self.gs.replace(Xy::OPAQUE), + self.clip.take(), + (self.block_start, self.block_end), + (std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)), + self.root_entries.take(), + std::mem::take(&mut self.wrote_pattern), + (std::mem::take(&mut self.wrote_fixed), std::mem::replace(&mut self.inner_scale, 1.0)), + ); + // Gradients' matrices and instances' matrices are written about the canvas point of art + // space's origin, y up; `Bg` places a gradient on the art as it lies in the symbol's space. + (self.to_art, self.grad_space, self.canvas, self.instance_space) = (Affine::IDENTITY, about_origin, Affine::IDENTITY, about_origin); + self.block_end = usize::MAX; + let registration = u16::from(doc.symbol_registration(name).0.min(8)) + 1; + let entries = format!("%_{registration} /Int (AI13PatternRegistrationType) ,\n"); + // The canvas draws a symbol's art even when its root is hidden, so the root is written shown. + let shown; + let root: &Node = if sym.art.visible { + &sym.art + } else { + let mut a = (*sym.art).clone(); + a.visible = true; + shown = a; + &shown + }; + // A definition holds a group: art of another kind, and a plain group of one object (which + // the reader takes for that group), are written in one. + let wrap = !matches!(root.kind, NodeKind::Group { clip: false, .. }) || crate::import::symbol_art_member(root).is_some(); + let nest = std::mem::replace(&mut self.nest, 1); + if wrap { + self.line("u"); + self.node(root); + let _ = write!(self.body, "U\n%_/ArtDictionary :\n{entries}%_;\n%_\n9 () XW\n"); + } else { + // A group always writes its dictionary ([`Self::dictionary`]), which takes the entries. + self.root_entries = Some((root.id, entries)); + self.node(root); + } + self.nest = nest; + // Objects the writing doesn't pass through one by one (clipping paths, a clipping group's + // own path) count as well. + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| self.wrote_fixed |= redrawn(m)); + if self.wrote_pattern { + self.pattern_written.insert(name.to_string()); + } + if self.wrote_fixed { + self.fixed_symbols.insert(name.to_string()); + } + if self.inner_scale > 1.0 { + self.symbol_scales.insert(name.to_string(), self.inner_scale); + } + let art; + ( + art, + (self.to_art, self.grad_space, self.canvas, self.instance_space), + self.state, + self.gs, + self.clip, + (self.block_start, self.block_end), + (self.in_symbol, self.hidden), + self.root_entries, + self.wrote_pattern, + (self.wrote_fixed, self.inner_scale), + ) = saved; + self.doc = page_doc; + let art = std::mem::replace(&mut self.body, art); + self.defining_symbols.pop(); + let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes())); + self.defined_symbols.insert(name.to_string()); + self.symbol_defs.push(text); + true + } + + /// Every symbol of the document defined, in its order (those its art uses before it): the + /// Symbols panel keeps the ones no instance uses too, and on a large canvas all of them. + pub(in crate::ai_editing) fn symbols(&mut self) { + let doc = self.symbol_doc; + for s in &doc.symbols { + self.define_symbol(&s.name); + } + } + + /// The symbol definitions, in the setup after the patterns: `%AI5_Begin_NonPrinting`, `Np`, each + /// definition, `%AI5_End_NonPrinting--`. + pub(in crate::ai_editing) fn symbol_definitions(&self, s: &mut String) { + if self.symbol_defs.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n"); + for d in &self.symbol_defs { + s.push_str(d); + } + s.push_str("%AI5_End_NonPrinting--\n"); + } + + /// The symbol list, after the palette: the names of the symbols defined in the document's order + /// (the Symbols panel's), between `%AI24_BeginSymbolList` and `%AI24_EndSymbolList` in a + /// non-printing block. + pub(in crate::ai_editing) fn symbol_list(&self, s: &mut String) { + if self.symbol_defs.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI24_BeginSymbolList\n"); + for sym in &self.symbol_doc.symbols { + if self.defined_symbols.contains(&sym.name) { + let _ = writeln!(s, "{}", ps::bytes(sym.name.as_bytes())); + } + } + s.push_str("%AI24_EndSymbolList\n%AI5_End_NonPrinting--\n"); + } +} diff --git a/crates/eps/src/ai_editing/writer/variables.rs b/crates/eps/src/ai_editing/writer/variables.rs new file mode 100644 index 000000000..b714b7ecd --- /dev/null +++ b/crates/eps/src/ai_editing/writer/variables.rs @@ -0,0 +1,372 @@ +//! The variable library: the document's variables, data sets and the objects bound to them, as XML +//! nodes of the `/Document` dictionary (see [`crate::ai_editing`] for the form). + +use std::collections::HashSet; +use std::fmt::Write; + +use vectorcraft_doc::{DataValue, Node, NodeId, NodeKind, VariableKind}; + +use super::Writer; +use crate::ps; + +pub(in crate::ai_editing) const BINDINGS: &str = "variable bindings to layers, to type written as outlines, to graphs, and to linked images written as other objects are left out of the editing data for other apps"; +pub(in crate::ai_editing) const VALUES: &str = "data set values of graphs, values that don't fit their variable, linked files that aren't absolute paths or that would read back as other paths, and values of variables whose names have a colon are left out of the editing data for other apps"; +pub(in crate::ai_editing) const ACTIVE: &str = + "the data set shown is left out of the editing data for other apps when it isn't one of the data sets written"; + +pub(in crate::ai_editing) const LIMITS: &str = "variables and data sets whose names are empty, repeated, longer than 255 characters or start or end with white space, variables past 256, data sets past 1,000, bindings past 100,000, bindings to objects whose XML ids are longer than 255 characters, values past 200,000 text lines and values in all, and names and values past 8,388,608 bytes in all are left out of the editing data for other apps"; + +/// The most bytes of a string literal on one line; a longer string is written across lines. +const LITERAL: usize = 64; +/// The reader's caps on a variable library: the variables, bindings and data sets it keeps, the +/// characters of a name and of an object's XML id, and the bytes of the names (a binding's variable name and +/// object id) and values it keeps. +const MAX_VARIABLES: usize = 256; +const MAX_BINDINGS: usize = 100_000; +const MAX_DATASETS: usize = 1_000; +const MAX_ID: usize = 255; +const MAX_BYTES: usize = 8 << 20; +/// The most XML nodes the data sets' values are written with: a value's element, and a text value's +/// `p` element and text for each line. +const MAX_VALUE_NODES: usize = 200_000; + +/// The bytes of names and values written so far, as the reader counts them, the XML nodes of the +/// values, and whether anything was left out past the caps. +#[derive(Default)] +struct Budget { + bytes: usize, + nodes: usize, + past: bool, +} + +impl Budget { + /// Whether `len` more bytes fit in [`MAX_BYTES`] (they are then counted). + fn keep(&mut self, len: usize) -> bool { + self.keep_value(len, 0) + } + + /// Whether a value of `len` bytes written with `nodes` XML nodes fits in [`MAX_BYTES`] and + /// [`MAX_VALUE_NODES`] (it is then counted). + fn keep_value(&mut self, len: usize, nodes: usize) -> bool { + let fits = self.bytes.saturating_add(len) <= MAX_BYTES && self.nodes.saturating_add(nodes) <= MAX_VALUE_NODES; + if fits { + self.bytes += len; + self.nodes += nodes; + } + fits + } +} + +/// The value of an XML node's entry, with the type it is written with. +#[derive(Clone, Copy)] +enum Value<'a> { + /// A `/String`. + Str(&'a str), + /// A `/UnicodeString`. + Unicode(&'a str), + /// A `/Bool`. + Bool(bool), + /// An object's XML id, `/XMLUIDREF`. + Uid(&'a str), +} + +/// `s` as string literals on `%_` lines, at most [`LITERAL`] bytes each, the first line's `%_` +/// included and the last line left open for what follows it. +fn literals(s: &str) -> String { + let mut parts = vec![]; + let mut start = 0; + for (at, c) in s.char_indices() { + if at + c.len_utf8() - start > LITERAL { + parts.push(&s[start..at]); + start = at; + } + } + parts.push(&s[start..]); + parts.iter().map(|p| format!("%_{}", ps::bytes(p.as_bytes()))).collect::>().join("\n") +} + +/// `v` written before an entry's key, from the start of its line. +fn value(v: Value) -> String { + match v { + Value::Str(s) => format!("{} /String", literals(s)), + Value::Unicode(s) => format!("{} /UnicodeString", literals(s)), + Value::Bool(b) => format!("%_{} /Bool", u8::from(b)), + Value::Uid(id) => format!("%_/XMLUIDREF : {} ;", ps::bytes(id.as_bytes())), + } +} + +/// An XML node (`/XMLNode`) up to the `;` that closes it: its name, its value, its type `kind` (1 an +/// element, 2 an attribute, 3 text, 9 the document), its children (an array, each closed as an item) +/// and its attribute nodes (a dictionary, each under its name). +fn node(kind: u8, name: &str, v: Value, attributes: &[(&str, Value)], children: &[String]) -> String { + let mut s = format!( + "%_/XMLNode :\n{} (xmlnode-nodename) ,\n{} (xmlnode-nodevalue) ,\n%_{kind} /Int (xmlnode-nodetype) ,\n%_/Array :\n", + value(Value::Str(name)), + value(v) + ); + for c in children { + s.push_str(c); + s.push_str("%_; ,\n"); + } + s.push_str("%_; (xmlnode-children) ,\n%_/Dictionary :\n"); + for (k, v) in attributes { + s.push_str(&node(2, k, *v, &[], &[])); + let _ = writeln!(s, "%_; {} ,", ps::bytes(k.as_bytes())); + } + s.push_str("%_; (xmlnode-attributes) ,\n"); + s +} + +/// An element `name` with `attributes` and `children`, up to the `;` that closes it. +fn element(name: &str, attributes: &[(&str, Value)], children: &[String]) -> String { + node(1, name, Value::Str(""), attributes, children) +} + +/// A text node (`#text`) holding `v`, up to the `;` that closes it. +fn text(v: Value) -> String { + node(3, "#text", v, &[], &[]) +} + +/// A linked file's path as a data set holds it: a path from the root (`/…`) as a `file://` URL +/// (`file:///` and the path, with `%`, `#`, `?`, white space, control characters and bytes outside ASCII +/// escaped as `%XX`), another absolute path (a drive's, or a network path) as it is. `None` for a +/// path that isn't absolute, for the root alone (`/`), for a path from the root whose first part +/// starts like a drive (`/C:…`, which the reader reads as the drive's path), and for one written as +/// it is with white space at either end. +fn file_value(path: &str) -> Option { + // A drive's path from its root (`C:\…`, `C:/…`); `C:name` is relative to the drive's folder. + let drive = matches!(path.as_bytes(), [l, b':', b'\\' | b'/', ..] if l.is_ascii_alphabetic()); + if path.starts_with('/') && !path.starts_with("//") { + let rest = &path.as_bytes()[1..]; + if rest.is_empty() || matches!(rest, [l, b':', ..] if l.is_ascii_alphabetic()) { + return None; + } + let mut url = String::from("file:///"); + for &b in path.as_bytes() { + if matches!(b, b'%' | b'#' | b'?') || !(0x21..=0x7e).contains(&b) { + let _ = write!(url, "%{b:02X}"); + } else { + url.push(char::from(b)); + } + } + return Some(url); + } + // The reader takes white space off both ends of a path written as it is. + let trimmed = path.trim_matches([' ', '\t', '\n', '\r']) == path; + (trimmed && path.len() > 1 && (path.starts_with('/') || path.starts_with("\\\\") || drive)).then(|| path.to_string()) +} + +/// Whether the reader reads `name` as a variable's or a data set's name as it is: not empty, at most +/// [`MAX_ID`] characters, and without white space at either end. +fn readable_name(name: &str) -> bool { + !name.is_empty() && name.trim() == name && name.chars().count() <= MAX_ID +} + +impl Writer<'_> { + /// The variables `n` is bound to that its XML id is written for: visibility variables, image + /// variables when `n` was written as a placed file (`placed`, see [`Self::placed`]), and text + /// variables when `n` was written as type (`typed`, see [`Self::typed`]). Only objects of the + /// document's own tree are bound, not copies of them (symbol art, live objects' art, opacity + /// masks' art, pattern tiles). + /// The bindings left out are reported by [`Self::variable_library`]. + pub(super) fn bound_variables(&self, n: &Node, placed: bool, typed: bool) -> Vec { + let doc = self.doc; + let Some(names) = doc.variables.bindings.get(&n.id) else { return vec![] }; + if self.nest > 0 || self.hidden > 0 || !doc.node(n.id).is_some_and(|d| std::ptr::eq(d, n)) { + return vec![]; + } + let fits = |name: &&String| match doc.variables.variable(name).map(|v| v.kind) { + Some(VariableKind::Visibility) => true, + Some(VariableKind::Image) => placed && matches!(n.kind, NodeKind::Image(_)), + Some(VariableKind::Text) => typed && matches!(n.kind, NodeKind::Text(_)), + _ => false, + }; + names.iter().filter(fits).cloned().collect() + } + + /// The `#document` entry of the `/Document` dictionary's `/Recorded` part that holds the + /// variable library, on `%_` lines: nothing when the document has no variables or data sets. + pub(in crate::ai_editing) fn variable_library(&mut self) -> String { + let doc = self.doc; + let vars = &doc.variables; + if vars.variables.is_empty() && vars.datasets.is_empty() && !vars.locked { + return String::new(); + } + // Bindings not written: to layers, to type written as outlines, to graphs, and to objects + // not written as themselves. Bindings to objects no longer in the document have nothing to + // write. + let pairs: HashSet<(NodeId, &str)> = self.bound.iter().flat_map(|(n, _, names)| names.iter().map(|name| (*n, name.as_str()))).collect(); + let written = |id: &NodeId, name: &String| pairs.contains(&(*id, name.as_str())); + let kind = |name: &str| vars.variable(name).map(|v| v.kind); + let missing = vars.bindings.iter().any(|(id, names)| { + doc.node(*id).is_some() && names.iter().any(|name| kind(name).is_some_and(|k| k != VariableKind::Unknown) && !written(id, name)) + }); + if missing { + self.warn(BINDINGS); + } + // What the reader keeps: names it doesn't read, and names, bindings and values past its + // caps, are left out. + let mut budget = Budget::default(); + let mut kept: Vec<&vectorcraft_doc::Variable> = vec![]; + for v in &vars.variables { + let new = readable_name(&v.name) && !kept.iter().any(|k| k.name == v.name); + if new && kept.len() < MAX_VARIABLES && budget.keep(v.name.len()) { + kept.push(v); + } else { + budget.past = true; + } + } + let mut declared = vec![]; + let mut bindings = 0usize; + for v in &kept { + let mut attributes = vec![]; + let kind = match v.kind { + VariableKind::Text => Some(("textcontent", "&ns_flows;")), + VariableKind::Visibility => Some(("visibility", "&ns_vars;")), + VariableKind::Image => Some(("fileref", "&ns_vars;")), + VariableKind::Graph => Some(("graphdata", "&ns_vars;")), + VariableKind::Unknown => None, + }; + if let Some((t, category)) = kind { + attributes.push(("trait", Value::Str(t))); + attributes.push(("category", Value::Str(category))); + } + attributes.push(("varName", Value::Unicode(&v.name))); + // A variable bound to several objects is declared once for each. + let objects = self.bound.iter().filter(|(_, _, names)| names.contains(&v.name)).map(|(_, id, _)| id.as_str()); + let mut written = 0; + for id in objects { + if bindings >= MAX_BINDINGS || id.chars().count() > MAX_ID || !budget.keep(v.name.len().saturating_add(id.len())) { + budget.past = true; + continue; + } + bindings += 1; + written += 1; + let mut with = attributes.clone(); + with.push(("docRef", Value::Uid(id))); + declared.push(element("variable", &with, &[])); + } + if written == 0 { + declared.push(element("variable", &attributes, &[])); + } + } + let mut left_out = false; + let mut rows = vec![]; + let mut row_names: Vec<&str> = vec![]; + for ds in &vars.datasets { + let new = readable_name(&ds.name) && !row_names.contains(&ds.name.as_str()); + if !new || rows.len() >= MAX_DATASETS || !budget.keep(ds.name.len()) { + budget.past = true; + continue; + } + let mut values = vec![]; + for (name, v) in &ds.values { + let Some(kind) = kept.iter().find(|k| &k.name == name).map(|k| k.kind) else { + // A value of a variable left out, or of none. + left_out |= vars.variable(name).is_none(); + continue; + }; + // The value's bytes as the reader counts them, and the nodes it is written with. + let (len, nodes) = match (v, kind) { + _ if name.contains(':') => { + left_out = true; + continue; + } + (DataValue::Text(t), VariableKind::Text) => { + let lines = t.bytes().filter(|b| *b == b'\n').count() + 1; + (t.len(), 1 + 2 * lines) + } + (DataValue::Visible(_), VariableKind::Visibility) => (1, 2), + (DataValue::Image(path), VariableKind::Image) if file_value(path).is_some() => (path.len(), 2), + _ => { + left_out = true; + continue; + } + }; + if !budget.keep_value(len, nodes) { + budget.past = true; + continue; + } + let children = match v { + DataValue::Text(t) => t + .split('\n') + .map(|line| { + let words = if line.is_empty() { vec![] } else { vec![text(Value::Unicode(line))] }; + element("p", &[], &words) + }) + .collect(), + DataValue::Visible(show) => vec![text(Value::Bool(*show))], + DataValue::Image(path) => file_value(path).map(|f| vec![text(Value::Unicode(&f))]).unwrap_or_default(), + DataValue::Graph(_) => vec![], + }; + values.push(element(name, &[], &children)); + } + rows.push(element("v:sampleDataSet", &[("dataSetName", Value::Unicode(&ds.name))], &values)); + row_names.push(&ds.name); + } + if left_out { + self.warn(VALUES); + } + if budget.past { + self.warn(LIMITS); + } + let mut attributes = vec![]; + // The data set shown, when a data set of that exact name is written. + if let Some(active) = &vars.active_dataset { + if row_names.contains(&active.as_str()) { + attributes.push(("i:activeDataSet", Value::Unicode(active))); + } else { + self.warn(ACTIVE); + } + } + attributes.push(("xmlns", Value::Str("&ns_custom;"))); + attributes.push(("xmlns:v", Value::Str("&ns_vars;"))); + let data_sets = element("v:sampleDataSets", &attributes, &rows); + let locked = if vars.locked { "variableAndTypes" } else { "none" }; + let set = element( + "variableSet", + &[("locked", Value::Str(locked)), ("varSetName", Value::Str("binding1"))], + &[element("variables", &[], &declared), data_sets], + ); + let sets = element("variableSets", &[("xmlns", Value::Str("&ns_vars;"))], &[set]); + let document = node(9, "#document", Value::Str(""), &[], &[element("metadata", &[], &[sets])]); + format!("{document}%_; (#document) ,\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_from_the_root_are_file_urls() { + assert_eq!(file_value("/Users/a b/100%.png").as_deref(), Some("file:////Users/a%20b/100%25.png")); + assert_eq!(file_value("/tmp/é.png").as_deref(), Some("file:////tmp/%C3%A9.png")); + assert_eq!(file_value("C:\\Art\\pic.png").as_deref(), Some("C:\\Art\\pic.png")); + assert_eq!(file_value("\\\\server\\share\\pic.png").as_deref(), Some("\\\\server\\share\\pic.png")); + assert_eq!(file_value("//server/share/pic.png").as_deref(), Some("//server/share/pic.png")); + assert_eq!(file_value("/a/#1?.png").as_deref(), Some("file:////a/%231%3F.png")); + assert_eq!(file_value("pic.png"), None); + assert_eq!(file_value("C:pic.png"), None); + assert_eq!(file_value("C:\\Art\\pic.png "), None); + assert_eq!(file_value("/tmp/pic.png ").as_deref(), Some("file:////tmp/pic.png%20")); + } + + #[test] + fn paths_the_reader_reads_as_other_paths_are_left_out() { + assert_eq!(file_value("/"), None); + assert_eq!(file_value("/a:b/x.png"), None); + assert_eq!(file_value("/C:/x.png"), None); + assert_eq!(file_value("/1:b/x.png").as_deref(), Some("file:////1:b/x.png")); + assert_eq!(file_value("/ab:c/x.png").as_deref(), Some("file:////ab:c/x.png")); + } + + #[test] + fn long_strings_are_written_across_lines() { + // 140 bytes: two lines of 64 bytes and one of 12. + let s = "é".repeat(70); + let lines = literals(&s); + assert_eq!(lines.lines().count(), 3); + assert!(lines.lines().all(|l| l.starts_with("%_("))); + } +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index e0110f3de..8bc8a174c 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,9 +70,9 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub(crate) use ai::MAX_GRADIENT_STOPS; +pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; -pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from}; +pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; pub use text::family_style; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 21c6a62aa..5537b5d06 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -35,13 +35,16 @@ use super::lex::find; use crate::ps; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; +#[cfg(test)] +pub(crate) use envelope::MAX_CHECKED; +pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS; pub use read::{ - BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, - variables, + BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, + read, read_beside, variables, }; -pub(crate) use read::{join_and, slot_frame, slot_of}; +pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; pub(crate) use read::is_look_slot; @@ -74,6 +77,19 @@ const MAX_ZSTD_WINDOW: u64 = 64 << 20; /// Most Zstandard frames read (the app writes one). const MAX_FRAMES: usize = 4096; +#[cfg(test)] +thread_local! { + /// How many bytes [`Codec::decode`] has looked at on this thread to find whether another + /// Zstandard frame follows or only zero padding is left. + static PADDING_LOOKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`PADDING_LOOKS`] of this thread. +#[cfg(test)] +fn padding_looks() -> u64 { + PADDING_LOOKS.get() +} + const EPS_BEGIN: &[u8] = b"%AI9_PrivateDataBegin"; const EPS_END: &[u8] = b"%AI9_PrivateDataEnd"; @@ -171,7 +187,11 @@ impl Codec { // can itself end in a zero byte, so the padding is what is left after the last frame. let mut src = data; let mut frames = 0usize; - while src.iter().any(|b| *b != 0) { + while src.iter().any(|b| { + #[cfg(test)] + PADDING_LOOKS.set(PADDING_LOOKS.get().saturating_add(1)); + *b != 0 + }) { frames += 1; if frames > MAX_FRAMES { return Err(damaged("its Zstandard compression")); diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 93d23f542..47fdc34fe 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -31,14 +31,15 @@ //! after a space for Art and Pattern), more fields, and last 15 numbers. //! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the //! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the -//! rotation follows the path (14). +//! rotation follows the path (14), and 1 Tints (1). //! - Art: 1 Tints (2). //! - Pattern: its scale as a factor (3). //! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle //! thickness, density, paint opacity and bristle length as fractions and the size in //! millimeters. //! - The art is a `%AI8_BeginBrushPattern` section: its name as a string, then the art (read by -//! [`super::read`]). +//! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame +//! at the art's bounds isn't kept ([`without_frame`]); a wider one is. //! //! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles //! (which slot is which tile isn't known) and the colorization of the other brushes, which is left @@ -70,6 +71,9 @@ const TOOLS: [(&str, Tool); 5] = [ pub(super) const MAX_BRUSHES: usize = 4096; /// Most brush art sections read from one file (a pattern brush has up to five). pub(super) const MAX_BRUSH_ART: usize = 5 * MAX_BRUSHES; +/// Most named colors kept from one brush art section, and brought in from the art of one file's +/// brushes, each name once per section: the rest are left out. +pub(super) const MAX_BRUSH_ART_COLORS: usize = 20_000; /// Most [`art_weight`] copied into the brushes of one file, each brush counting its own copy. const MAX_BRUSH_ART_NODES: usize = 250_000; @@ -324,7 +328,8 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< scatter, rotation, rotation_relative_to_path: *p14 == 1.0, - colorization: Colorization::None, + // The first of the 15 numbers: 1 is Tints. + colorization: if n.first() == Some(&1.0) { Colorization::Tints } else { Colorization::None }, modes: Some(modes), ..Scatter::default() }; @@ -468,3 +473,79 @@ pub(super) fn finish_brushes_within( pub(super) fn left_out_note(n: usize) -> String { format!("brushes VectorCraft can't read yet were left out of the Brushes panel ({n})") } + +/// Is `n` a frame: an unnamed, unpainted rectangle with nothing of its own (shown, unlocked, at full +/// opacity in Normal, with no effects, note, mask or other settings), a path of one subpath whose +/// anchors, without handles, are its four corners in order around it: closed, with the first again +/// at the end or not, or open with the first again at the end? +fn unpainted_rectangle(n: &Node) -> bool { + let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind else { return false }; + let ([sp], Some(b)) = (path.subpaths.as_slice(), n.geometric_bounds()) else { return false }; + let corner = |p: kurbo::Point| (p.x == b.x0 || p.x == b.x1) && (p.y == b.y0 || p.y == b.y1); + let mut corners: Vec = vec![]; + for a in &sp.anchors { + if !corners.contains(&a.p) { + corners.push(a.p); + } + } + let last_repeats = sp.anchors.len() == 5 && sp.anchors.first().map(|a| a.p) == sp.anchors.last().map(|a| a.p); + // Each corner to the next goes along a side, not across: one coordinate stays. + let sides = corners.len() == 4 + && (0..4).all(|i| match (corners.get(i), corners.get((i + 1) % 4)) { + (Some(a), Some(b)) => (a.x == b.x) != (a.y == b.y), + _ => false, + }); + n.name.is_none() + && *n.extras() == vectorcraft_doc::NodeExtras::default() + && n.visible + && !n.locked + && n.opacity >= 1.0 + && n.blend == vectorcraft_color::BlendMode::Normal + && !n.isolate + && n.knockout == vectorcraft_doc::Knockout::Neutral + && !n.knockout_shape + && n.appearance.effects.is_empty() + && n.appearance.items.iter().all(|i| i.paint().is_none()) + && ((sp.closed && sp.anchors.len() == 4) || last_repeats) + && sides + && b.width() > 0.0 + && b.height() > 0.0 + && sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p && corner(a.p)) +} + +/// Brush art `art` without its frame: a group whose first object is an unpainted rectangle around +/// the rest, exactly (within 1/1000 pt), loses that rectangle, and a group without a look or state +/// of its own (hidden, locked, isolation, knockout, opacity, blend mode, name, appearance, or a mask, +/// note or other setting) that is left with one object is that object. A frame wider than the art +/// is kept. +pub(super) fn without_frame(art: Node) -> Node { + let NodeKind::Group { children, clip: false } = &art.kind else { return art }; + let Some((first, rest)) = children.split_first() else { return art }; + let around = rest.iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)); + let exact = match (around, first.geometric_bounds()) { + (Some(a), Some(f)) => [a.x0 - f.x0, a.y0 - f.y0, a.x1 - f.x1, a.y1 - f.y1].iter().all(|d| d.abs() <= 1e-3), + _ => false, + }; + if !exact || !unpainted_rectangle(first) { + return art; + } + let plain = art.opacity >= 1.0 + && art.visible + && !art.locked + && !art.isolate + && art.knockout == vectorcraft_doc::Knockout::Neutral + && !art.knockout_shape + && art.blend == vectorcraft_color::BlendMode::Normal + && art.name.is_none() + && *art.extras() == vectorcraft_doc::NodeExtras::default() + && art.appearance.effects.is_empty() + && art.appearance.items.is_empty(); + if let ([only], true) = (rest, plain) { + return Node::clone(only); + } + let mut out = art.clone(); + if let Some(c) = out.children_mut() { + c.remove(0); + } + out +} diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index d9f392215..bccaa050a 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -23,7 +23,7 @@ //! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its //! drawn art. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; @@ -31,7 +31,7 @@ use vectorcraft_doc::live::{ EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity, envelope_content_boxes, envelope_stroke_margin, }; -use vectorcraft_doc::{Document, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef}; +use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef}; use vectorcraft_geom::PathData; use super::obj::Obj; @@ -41,10 +41,12 @@ use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compa pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use /// The note for an envelope that keeps its drawn art. pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art"; +/// The note for warped type that keeps its drawn art, before how many. +pub(crate) const WARPED_TYPE_DRAWN: &str = "warped text objects that came in as drawn art, not as editable type"; /// The most envelopes waiting for their drawn art at once. pub(super) const MAX_PENDING: usize = 64; -/// The most envelopes one import draws to check; more keep their drawn art. -pub(super) const MAX_CHECKED: usize = 256; +/// The most envelopes and warped text objects one import draws to check; more keep their drawn art. +pub(crate) const MAX_CHECKED: usize = 256; /// The most rows or columns of a mesh read (Make with Mesh's own limit). const MAX_GRID: u32 = 50; /// How far, in points over width and height together, a saved size may lie from the contents' @@ -343,14 +345,107 @@ fn differing(live: &Document, drawn: &Document, region: Rect, scale: f64) -> Opt Some((any, solid, inked)) } -/// The differences from their drawn art that the live envelopes of one import may add up to, as -/// shares of the page drawn as the page check draws it: half of what that check allows -/// ([`MAX_DIFFERENCE`], [`MAX_SOLID_DIFFERENCE`]), so live envelopes never make the file open from -/// its PDF part instead. +/// The differences from their drawn art that the live envelopes and warped type of one import may +/// add up to, as shares of the page drawn as the page check draws it: half of what that check allows +/// ([`MAX_DIFFERENCE`], [`MAX_SOLID_DIFFERENCE`]), so they never make the file open from its PDF part +/// instead. #[derive(Debug, Default)] -pub(super) struct Spent { - any: f64, - solid: f64, +pub(crate) struct Spent { + pub(super) any: f64, + pub(super) solid: f64, +} + +/// A text object read with a Warp. +#[derive(Debug)] +pub(super) struct WarpedLook { + /// The id of its slot. + pub(super) slot: NodeId, + /// The drawn art its style's look saved. + pub(super) look: Node, + /// That art's box, when the comparisons with the page leave it out ([`super::Structure::restyled`]). + pub(super) left_out: Option, +} + +/// The text objects read with a Warp, and what the import has left of the budget for checks +/// ([`drawn_alike`]). Type is laid out after the layers are read, so these wait for that +/// ([`Self::settle`]). +#[derive(Debug, Default)] +pub struct WarpedType { + pub(super) looks: Vec, + pub(super) checks: usize, + pub(super) spent: Spent, + pub(super) page: Option, +} + +impl WarpedType { + /// Replace each warped text object of `doc` that doesn't draw as its saved look (or that the + /// budget has no check left for) with that look: `made` gives the text object a slot became. A + /// frame in `threaded` stays type, since the frames of a thread share one story. A replaced + /// object's box goes back into the comparisons with the page (out of `restyled`), and only what + /// `doc` shows counts against the page's share of the budget. Returns how many were replaced. + pub(crate) fn settle( + self, + doc: &mut Document, + made: impl Fn(NodeId) -> Option, + threaded: &BTreeSet, + restyled: &mut Vec, + ) -> usize { + let Self { looks, mut checks, mut spent, page } = self; + let mut shown_ids = BTreeSet::new(); + doc.layers.iter().for_each(|l| collect_shown(l, &mut shown_ids)); + let mut replaced = 0; + for WarpedLook { slot, mut look, left_out } in looks { + let Some(id) = made(slot).filter(|id| !threaded.contains(id)) else { continue }; + let Some(text) = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)) && warps(&n.appearance)).cloned() else { continue }; + let mut alike = false; + if checks > 0 { + checks -= 1; + let layer = doc.alloc_id(); + let shared = Shared { patterns: &doc.patterns, images: &doc.images }; + let (mut shown, mut plain) = (text.clone(), look.clone()); + (shown.visible, shown.opacity) = (true, 1.0); + (plain.visible, plain.opacity) = (true, 1.0); + alike = warped_alike(&shown, &plain, layer, &shared, page.filter(|_| shown_ids.contains(&id)), &mut spent); + } + if alike { + continue; + } + (look.id, look.name, look.visible, look.locked) = (id, text.name.clone(), text.visible, text.locked); + if let Some(wrap) = text.extras().wrap { + look.extras_mut().wrap = Some(wrap); + } + if let Some(n) = doc.node_mut(id) { + *n = look; + replaced += 1; + if let Some(at) = left_out.and_then(|b| restyled.iter().position(|r| *r == b)) { + restyled.remove(at); + } + } + } + replaced + } +} + +#[cfg(test)] +impl WarpedType { + /// The text objects of `looks` (slot and saved look) with no check left. + pub(crate) fn unchecked(looks: Vec<(NodeId, Node)>) -> Self { + Self { looks: looks.into_iter().map(|(slot, look)| WarpedLook { slot, look, left_out: None }).collect(), ..Default::default() } + } +} + +/// Add to `out` the ids of `n` and what is in it, unless hidden. +fn collect_shown(n: &Node, out: &mut BTreeSet) { + if n.visible { + out.insert(n.id); + n.children().into_iter().flatten().for_each(|c| collect_shown(c, out)); + } +} + +/// Does `appearance` have a Warp that shows, on the object or on one of its fills or strokes? +pub(super) fn warps(appearance: &Appearance) -> bool { + let warp = |e: &Effect| e.visible && e.id.starts_with("warp."); + appearance.effects.iter().any(warp) || appearance.items.iter().any(|i| i.visible() && i.effects().iter().any(warp)) } /// Do `live` and `drawn` draw alike: few of the pixels either inks differ, and fewer still where a @@ -358,7 +453,19 @@ pub(super) struct Spent { /// with the differences in `spent` the page check still takes the layers' art (it is added to /// `spent` when they do)? Both are drawn on `layer`, a layer id no other node has. pub(super) fn drawn_alike(live: &Node, drawn: &Node, layer: NodeId, shared: &Shared<'_>, page: Option, spent: &mut Spent) -> bool { - let (Some(a), Some(b)) = (live.visual_bounds(), drawn.visual_bounds()) else { return false }; + alike_within(live, drawn, live.visual_bounds(), layer, shared, page, spent) +} + +/// [`drawn_alike`] for type under a Warp, compared over all that its effects draw, which can reach +/// past its own box. +pub(super) fn warped_alike(live: &Node, drawn: &Node, layer: NodeId, shared: &Shared<'_>, page: Option, spent: &mut Spent) -> bool { + let reach = vectorcraft_render::effect_bounds(&alone(live, layer, shared).symbol_boxes(), live); + alike_within(live, drawn, vectorcraft_geom::union_opt(live.visual_bounds(), reach), layer, shared, page, spent) +} + +/// [`drawn_alike`], with `live` measured as `reach`. +fn alike_within(live: &Node, drawn: &Node, reach: Option, layer: NodeId, shared: &Shared<'_>, page: Option, spent: &mut Spent) -> bool { + let (Some(a), Some(b)) = (reach, drawn.visual_bounds()) else { return false }; let region = a.union(b).inflate(2.0, 2.0); if !(region.is_finite() && region.width() > 0.0 && region.height() > 0.0) || region.width().max(region.height()) > 1e6 { return false; diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 4e6164e4b..a1b24662e 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -3,17 +3,21 @@ //! type runs along, `TP`), the modal character and paragraph attributes //! (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's letter and word spacing `TC TW`, hanging //! punctuation `Tq`, hyphenation `Th` and tab stops `Xb Xe XB`, and §15.3.4's writing direction `TV`, -//! Roman character rotation `Tv` and Kumi blocks `Ty TY`), the paint style, and the characters (`Tx`, `Tj`, -//! overflow `TX`; `\r` ends a paragraph). Type set in rendering modes 4 to 7 is a mask (§5.7): the +//! Roman character rotation `Tv`, Kumi blocks `Ty TY` and binding punctuation `Tg`), the paint +//! style, and the characters (`Tx`, `Tj`, overflow `TX`; `\r` ends a paragraph). Type set in rendering modes 4 to 7 is a mask (§5.7): the //! text object clips the objects of its clipping group. Final-form operators (the //! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the //! encoding pairs of the current font's `TZ`, then by the file's `TE` encoding unless that `TZ`'s //! `useDefault` is 0, else as Windows-1252; a re-encoded font `/_name` is the font its `%AI3_BeginEncoding` or `TZ` names -//! (else `name`). +//! (else `name`). Strings in a composite font named `base-CMap` (CJK type) whose `TZ` gives no +//! encoding pairs are decoded by that predefined CMap instead, and the font is `base`. +use std::sync::Arc; + +use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen}; use vectorcraft_color::Paint; -use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{Burasagari, CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun}; use vectorcraft_geom::AnchorKind; use super::super::obj::V; @@ -78,6 +82,17 @@ struct ParaRun { tabs_version: u64, } +/// An overflow paragraph break (`(\r) TX`) read before any more type, with the state in force +/// where it was read. +#[derive(Debug)] +struct PendingBreak { + /// `TV` at the break. + vertical: bool, + /// The attributes and [`TypeState::tabs_version`] of the empty paragraph the break ends, when + /// it ends one that no character started. + para: Option<(ParaStyle, u64)>, +} + /// A text object being read. #[derive(Debug, Default)] pub(super) struct LegacyText { @@ -101,7 +116,7 @@ pub(super) struct LegacyText { /// The current paragraph's attributes are in `paras`. para_taken: bool, /// An overflow paragraph break (`(\r) TX`) not yet followed by type: the story's end. - pending_break: bool, + pending_break: Option, /// Characters were set in a mask rendering mode (`Tr` 4 to 7): the text object is a clipping /// path (§5.7). clips: bool, @@ -195,8 +210,14 @@ pub(super) struct TypeState { /// Re-encoded fonts' own encodings (`TZ`), by the new font's name: what its encoding pairs /// change, by code (None where they say nothing), and whether `TE`'s encoding applies to it. font_encodings: Vec<(String, Vec>, bool)>, + /// The new fonts' names whose `TZ` gives encoding pairs, which no composite font has. + paired_fonts: Vec, /// The font `Tf` set last, as the file names it. font: Option, + /// How strings in that font decode, when it is a composite font ([`composite_font`]). + cmap: Option>, + /// The predefined CMaps read so far, by name (`None` when one couldn't be read). + cmaps: Vec<(String, Option>)>, /// Between `Xb` and `XB`: the tab stops of the tab definition read so far. tabs: Option>, chars: usize, @@ -214,7 +235,10 @@ impl Default for TypeState { encoding: vec![None; 256], fonts: vec![], font_encodings: vec![], + paired_fonts: vec![], font: None, + cmap: None, + cmaps: vec![], tabs: None, chars: 0, } @@ -240,6 +264,9 @@ impl TypeState { /// A string's bytes, in the current font. fn decode(&self, bytes: &[u8]) -> String { + if let Some(cmap) = &self.cmap { + return cmap.decode(bytes); + } let own = self.font.as_ref().and_then(|f| self.font_encodings.iter().find(|(n, _, _)| n == f)); let (own, te) = own.map_or((None, true), |(_, e, te)| (Some(e.as_slice()), *te)); bytes @@ -254,6 +281,260 @@ impl TypeState { }) .collect() } + + /// How strings in a font with predefined CMap `cmap` decode; `None` when the CMap can't be read. + fn cmap_codes(&mut self, cmap: &str) -> Option> { + if let Some(form) = unicode_cmap(cmap) { + return Some(Arc::new(CMapCodes::Unicode(form))); + } + if let Some((_, codes)) = self.cmaps.iter().find(|(n, _)| n == cmap) { + return codes.clone(); + } + let codes = cid_codes(cmap).map(Arc::new); + self.cmaps.push((cmap.to_string(), codes.clone())); + codes + } +} + +/// How a composite font's strings decode. +#[derive(Debug)] +enum CMapCodes { + /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; + /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's + /// code space ([`code_space`]). + Cids { codes: Box, unicode: Box, high_bit: bool, space: Option }, + /// A predefined Unicode CMap: the codes are the characters. + Unicode(Unicode), +} + +/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the +/// code's length in bytes. +type CodeSpace = &'static [(&'static [u8], &'static [u8])]; + +/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. +fn control(b: u8) -> Option { + match b { + b'\r' => Some('\n'), + b'\t' => Some('\t'), + _ => None, + } +} + +impl CMapCodes { + /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a + /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a + /// UTF-16 or UTF-32 string). + fn decode(&self, bytes: &[u8]) -> String { + let (codes, unicode, high_bit, space) = match self { + Self::Unicode(form) => { + let unit = match form { + Unicode::Utf16 => 2, + Unicode::Utf8 => 1, + Unicode::Utf32 => 4, + }; + let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); + let mut text = unicode_text(whole, *form).replace('\r', "\n"); + text.extend(rest.last().copied().and_then(control)); + return text; + } + Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), + }; + let euc: Vec; + let bytes = if high_bit { + euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); + euc.as_slice() + } else { + bytes + }; + let mut out = String::new(); + let mut at = 0; + while let Some(&first) = bytes.get(at) { + // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every + // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space + // has no one-byte codes. + if let Some(control) = control(first) { + out.push(control); + at += 1; + continue; + } + let rest = bytes.get(at..).unwrap_or_default(); + let code = |len: u8| rest.get(..usize::from(len)).map(|b| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b))); + let found = match space { + // The code is as long as the code space range it falls in; a byte that starts no + // code in the code space is skipped. + Some(space) => { + let Some(len) = code_len(space, rest) else { + at += 1; + continue; + }; + code(len).map(|c| (len, codes.lookup_cid_code(c, len))) + } + None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), + }; + let Some((len, cid)) = found else { + at += 1; + continue; + }; + at += usize::from(len); + // A code the CMap leaves out is dropped. + let Some(cid) = cid else { continue }; + match unicode.lookup_bf_string(cid) { + // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in + // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. + Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => out.push(char::from(first)), + Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => out.push(c), + Some(BfString::String(s)) => out.extend(s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), + _ => {} + } + } + out + } +} + +/// How a predefined Unicode CMap's codes encode their characters. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Unicode { + /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. + Utf16, + Utf8, + /// Four bytes, the high byte first. + Utf32, +} + +/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, +/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` +/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ). +fn unicode_cmap(name: &str) -> Option { + let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; + let rest = rest.strip_suffix("-HW").unwrap_or(rest); + let (collection, form) = rest.rsplit_once('-')?; + let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; + if !collections.contains(&collection) { + return None; + } + match form { + "UCS2" | "UTF16" => Some(Unicode::Utf16), + "UTF8" => Some(Unicode::Utf8), + "UTF32" => Some(Unicode::Utf32), + _ => None, + } +} + +/// The characters string `s` encodes in Unicode form `form` (invalid codes left out). +fn unicode_text(s: &[u8], form: Unicode) -> String { + match form { + Unicode::Utf16 => { + let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); + char::decode_utf16(units).filter_map(Result::ok).collect() + } + Unicode::Utf8 => s.utf8_chunks().map(|chunk| chunk.valid()).collect(), + Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), + } +} + +/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose +/// codes are CIDs of no known character collection. +fn predefined(name: &str) -> Option> { + match CMapName::from_bytes(name.as_bytes()) { + CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, + name => Some(name), + } +} + +/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` +/// (): the nearest carried CMap with the same +/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC +/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space +/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. +fn stand_in(stem: &str) -> Option> { + Some(match stem { + "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), + "78-EUC" => Some(("EUC-H", false)), + "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), + "GB" => Some(("GB-EUC-H", true)), + "CNS1" => Some(("CNS-EUC-H", true)), + "KSC" => Some(("KSC-EUC-H", true)), + "B5" => Some(("ETen-B5-H", false)), + "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), + "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, + _ => return None, + }) +} + +/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A +/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two +/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with +/// its [`stand_in`]. +fn cid_codes(cmap: &str) -> Option { + let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); + let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); + let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); + let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { + let (name, high_bit) = stand_in(stem?).flatten()?; + Some((read(name)?, high_bit)) + })?; + let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; + let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; + // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. + let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; + Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) +} + +/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it +/// (); a CMap's vertical form has its +/// horizontal form's. `None` for a CMap not listed here. +fn code_space(cmap: &str) -> Option { + const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; + const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; + const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; + const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; + const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; + const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; + const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; + const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; + const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; + const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; + const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; + const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; + const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; + const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; + let stem = match cmap { + "H" | "V" => "", + _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, + }; + let stem = stem.strip_suffix("-HW").unwrap_or(stem); + Some(match stem { + "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, + _ if stem.ends_with("RKSJ") => RKSJ, + "EUC" | "78-EUC" => EUC_JP, + "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, + "GBpc-EUC" | "GBTpc-EUC" => GB_PC, + "GBK-EUC" | "GBKp-EUC" => GBK, + "GBK2K" => GBK2K, + "B5pc" => B5_PC, + "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, + "HKscs-B5" | "ETHK-B5" => HKSCS, + "HKdlb-B5" => HKDLB, + "HKgccs-B5" => HKGCCS, + "CNS-EUC" => CNS_EUC, + "KSCpc-EUC" => KSC_PC, + "KSCms-UHC" => UHC, + _ => return None, + }) +} + +/// The length of the code string `bytes` starts with: the length of the code space range whose +/// lowest and highest codes hold each of its bytes between them. `None` when no range does. +fn code_len(space: CodeSpace, bytes: &[u8]) -> Option { + space + .iter() + .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) + .map(|(lo, _)| lo.len()) + .min() + .and_then(|len| u8::try_from(len).ok()) } /// Encoding pairs (§3.3.1): codes, each followed by the glyph names from it on, into `table`. @@ -275,6 +556,36 @@ fn encoding_pairs(table: &mut [Option], vals: &[V]) { } } +/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a +/// predefined Unicode CMap ([`unicode_cmap`]), one of the predefined CMaps VectorCraft carries +/// ([`predefined`]) or one of the others ([`stand_in`]), in its horizontal (`-H`) or vertical +/// (`-V`) form. A name ending in an Identity CMap is not read as one, nor is one that ends in an +/// unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise end in `H`. +fn composite_font(name: &str) -> Option<(&str, &str)> { + if !(name.ends_with("-H") || name.ends_with("-V")) { + return None; + } + // The longest CMap name first: `83pv-RKSJ-H` before `H`. + // No predefined CMap name is longer than 32 bytes. + for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { + let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; + if matches!(cmap, "Identity-H" | "Identity-V") { + return None; + } + let encoding_part = + base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); + if matches!(cmap, "H" | "V") && encoding_part { + return None; + } + let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); + let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); + if unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() { + return Some((base, cmap)); + } + } + None +} + /// An operator's `N` operands, the last `N` values before it, when each is a finite number. fn nums(vals: &[V]) -> Option<[f64; N]> { let operands = vals.get(vals.len().checked_sub(N)?..)?; @@ -330,6 +641,9 @@ impl Reader<'_> { let pairs = pairs.iter().rposition(|v| matches!(v, V::Mark)).map_or(pairs, |m| pairs.get(m + 1..).unwrap_or_default()); let mut table = vec![None; 256]; encoding_pairs(&mut table, pairs); + if pairs.iter().any(|v| matches!(v, V::Name(_))) { + st.paired_fonts.push(new.clone()); + } st.font_encodings.push((new.clone(), table, te)); } @@ -458,9 +772,17 @@ impl Reader<'_> { let Some(at) = vals.iter().position(|v| matches!(v, V::Name(_))) else { return }; if let Some(V::Name(name)) = vals.get(at) { st.font = Some(name.clone()); - let name = + let full = st.fonts.iter().find(|(new, _)| new == name).map_or_else(|| name.trim_start_matches('_').to_string(), |(_, old)| old.clone()); - let (family, style) = super::super::super::text::family_style(&name); + // A font whose `TZ` gives encoding pairs is re-encoded, which a composite font + // can't be; a `TZ` without pairs only names the font. + let reencoded = st.paired_fonts.contains(name); + let (name, cmap) = match composite_font(&full).filter(|_| !reencoded) { + Some((base, cmap)) => (base, st.cmap_codes(cmap)), + None => (full.as_str(), None), + }; + st.cmap = cmap; + let (family, style) = super::super::super::text::family_style(name); st.style.font_family = family; st.style.font_style = style; st.style.font_version = None; @@ -548,6 +870,19 @@ impl Reader<'_> { st.para.roman_hanging = on; } } + // `bindrepeat Tg` (§15.3.4): 1 and 3 turn binding punctuation on, and 0 and 2 turn it + // off. VectorCraft's own mapping opens it on as Burasagari's Force setting and off as no + // Burasagari. The repeat character (2 and 3) is not kept. + "Tg" => { + let burasagari = match n0 { + Some(1.0 | 3.0) => Some(Burasagari::Forced), + Some(0.0 | 2.0) => Some(Burasagari::None), + _ => None, + }; + if let Some(b) = burasagari { + st.para.burasagari = b; + } + } // `hyphenate limitHyphLines minLeadHyphen minTailHyphen maxHyphLines Th`, kept to the // ranges `text.setFormat` takes. "Th" => { @@ -632,7 +967,10 @@ impl Reader<'_> { } // Overflow text ending the story with a paragraph break (`(\r) TX`) adds no paragraph. if w == "TX" && decoded == "\n" { - t.pending_break = true; + if t.pending_break.is_none() { + let para = (!t.para_taken).then(|| (st.para.clone(), st.tabs_version)); + t.pending_break = Some(PendingBreak { vertical: st.vertical, para }); + } return; } // As many characters as the file's allowance has left. @@ -642,10 +980,13 @@ impl Reader<'_> { } let decoded: String = decoded.chars().take(room).collect(); st.chars += decoded.chars().count(); + // A break read before these characters keeps the direction and paragraph attributes it was + // read with, and is the story's first character when nothing came before it. + let brk = t.pending_break.take(); if t.runs.is_empty() { - t.vertical = st.vertical; + t.vertical = brk.as_ref().map_or(st.vertical, |b| b.vertical); } - t.direction_changed |= st.vertical != t.vertical; + t.direction_changed |= st.vertical != t.vertical || brk.as_ref().is_some_and(|b| b.vertical != t.vertical); let gs = &self.gs; let mut style = st.style.clone(); style.tate_chu_yoko |= st.kumi_recorded && !t.vertical; @@ -664,7 +1005,10 @@ impl Reader<'_> { (style.stroke_cap, style.stroke_join, style.stroke_miter_limit, style.stroke_dash) = (gs.cap, gs.join, gs.miter, gs.dash.clone()); (style.overprint_fill, style.overprint_stroke) = (gs.overprint_fill, gs.overprint_stroke); let mut text = String::new(); - if std::mem::take(&mut t.pending_break) { + if let Some(brk) = brk { + if let Some((mut para, tabs_version)) = brk.para { + t.begin_para(&mut para, tabs_version); + } text.push('\n'); t.para_taken = false; } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 025ccc480..60ccd43ca 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -134,10 +134,13 @@ mod types; pub(crate) use art::join_and; use art::preview_png; -pub use document::{BindingRef, Brushes, PaletteSwatches, VariableRead, artboards, bindings_note, brushes, graphic_styles, palette, variables}; +pub use document::{ + BindingRef, Brushes, PaletteSwatches, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, variables, +}; pub(crate) use document::{slot_frame, slot_of}; +pub(crate) use ops::read_art_style; pub use types::IMAGE_TOO_LARGE; -use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; +use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; #[cfg(test)] pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; pub(super) use types::{blend_mode, blend_name}; @@ -145,6 +148,10 @@ pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] mod legacy_text; +// The `.ai` writer defines symbols as this reader keeps them. +pub(crate) use directives::symbol_art_member; +pub(crate) use types::MAX_SYMBOLS; + /// What the editing data holds: the document of its art, and where its art space is. #[derive(Debug)] pub struct Structure { @@ -181,6 +188,8 @@ pub struct Structure { /// The live envelopes read from the editing data, by id, with the drawn art each took the place /// of: the importer puts that art back when the layers with the envelopes don't draw as the page. pub envelopes_drawn: Vec<(NodeId, Node)>, + /// The warped text objects, checked against their drawn looks once their type is laid out. + pub warped_type: super::envelope::WarpedType, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. @@ -493,10 +502,13 @@ struct Reader<'a> { envelope_art: Option, /// Envelopes waiting for their drawn art, innermost last. envelopes: Vec, - /// How many more envelopes this import may draw to check ([`super::envelope::drawn_alike`]). + /// How many more envelopes and warped text objects this import may draw to check + /// ([`super::envelope::drawn_alike`]). envelope_checks: usize, /// How far the live envelopes read so far draw from their drawn art ([`super::envelope::Spent`]). envelope_spent: super::envelope::Spent, + /// The text objects read with a Warp ([`super::envelope::WarpedType`]). + warped_type: Vec, /// The live envelopes made so far and the drawn art each took the place of /// ([`Structure::envelopes_drawn`]). envelopes_drawn: Vec<(NodeId, Node)>, @@ -513,7 +525,8 @@ struct Reader<'a> { /// The state a layer's last group or compound path had before an `Xy` right after its end, /// which is the layer's instead when the layer's own `7 () XW` follows. container_undo: Option<(f32, BlendMode, bool, Knockout, bool)>, - /// Painted copies of clipping paths that have a fill (drawn under what they clip). + /// The fills of compound clipping paths whose subpaths paint differently, as compound paths of their own (drawn + /// under what they clip). clip_fills: HashSet, /// Groups closed as an object's drawn look (`9 () XW`). looks: HashSet, @@ -585,6 +598,8 @@ struct Reader<'a> { brush_drafts: Vec, /// The art of the brushes (`%AI8_BeginBrushPattern`), by name. brush_art: HashMap, + /// The colors of the art in [`Reader::brush_art`], by name. + brush_art_colors: HashMap, /// The brush art sections read so far, repeated names included. brush_art_sections: usize, /// What the brushes and brush art read so far give a brushed stroke. @@ -624,6 +639,9 @@ impl<'a> Reader<'a> { } fn new(data: &'a [u8]) -> Self { + // An envelope's saved margins and its check against its drawn art measure type by its + // glyphs, as the app maps it. + vectorcraft_text::register_envelope_ink(); let mut doc = Document::new(1.0, 1.0); doc.layers.clear(); Self { @@ -695,6 +713,7 @@ impl<'a> Reader<'a> { envelopes: Vec::new(), envelope_checks: super::envelope::MAX_CHECKED, envelope_spent: super::envelope::Spent::default(), + warped_type: Vec::new(), envelopes_drawn: Vec::new(), clip_fills: HashSet::new(), looks: HashSet::new(), @@ -735,6 +754,7 @@ impl<'a> Reader<'a> { brush_order: None, brush_drafts: Vec::new(), brush_art: HashMap::new(), + brush_art_colors: HashMap::new(), brush_art_sections: 0, brushes_kept: BrushesKept::default(), brushes_unread: 0, diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index b1b860a25..bc7b88155 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -216,13 +216,11 @@ impl<'a> Reader<'a> { // A gradient instance's paint replaces the missing pattern. let unread = (fill && self.pending_unread_fill && fill_paint.is_none()) || (stroke && self.pending_unread_stroke && stroke_paint.is_none()); let mut items = Vec::new(); - if fill && !clip { - items.push(AppearanceItem::Fill(FillLayer { - overprint: gs.overprint_fill, - ..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone())) - })); + // A clipping path keeps its fill and its stroke: its fill paints under the art it clips and its stroke over it + // (`Node::clip_paint`), as the file's PDF part draws them. + if fill { + items.push(AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(fill_paint.unwrap_or(gs.fill.clone())) })); } - // A clipping path keeps its stroke, which paints over the art it clips. if stroke { let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.line_width()); st.cap = gs.cap; @@ -254,23 +252,6 @@ impl<'a> Reader<'a> { if clip && let Some(f) = self.frames.last_mut() { f.clips.push(f.children.len()); } - // A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it. - let painted = clip && fill && !matches!(gs.fill, Paint::None); - let copy = painted.then(|| { - let mut copy = node.clone(); - copy.id = self.doc.alloc_id(); - if let NodeKind::Path { clipping, .. } = &mut copy.kind { - *clipping = false; - } - let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone()); - copy.appearance = Appearance { - items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })], - ..Appearance::default() - } - .into(); - copy - }); - // The note and Show Center are the path's, not its painted copy's. node.edit_attrs(|a| { a.note = gs.note.clone().unwrap_or_default(); a.show_center = gs.show_center; @@ -297,10 +278,6 @@ impl<'a> Reader<'a> { } } } - if let Some(copy) = copy { - self.clip_fills.insert(copy.id); - self.add(copy, hidden)?; - } Ok(()) } @@ -342,19 +319,13 @@ impl<'a> Reader<'a> { Ok(()) } - /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. A clipping path - /// takes the stroke itself, not the painted copy of its fill. + /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. pub(super) fn stroke_last(&mut self) { let gs = self.gs.clone(); if !self.after_object { return; } - let Some(kids) = self.frames.last_mut().map(|f| &mut f.children) else { return }; - let Some(mut at) = kids.len().checked_sub(1) else { return }; - if kids.get(at).is_some_and(|n| self.clip_fills.contains(&n.id)) && at > 0 { - at -= 1; - } - let Some(n) = kids.get_mut(at) else { return }; + let Some(n) = self.frames.last_mut().and_then(|f| f.children.last_mut()) else { return }; if !matches!(n.kind, NodeKind::Path { .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) { return; } diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 7d7c9ed9b..a08f2ea1c 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -147,10 +147,19 @@ impl<'a> Reader<'a> { self.new_node(NodeKind::Group { children, clip }, &gs) } Kind::Compound => { - // A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths' - // painted copies make a compound of their own, under what it clips. - let (fills, kids): (Vec, Vec) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id)); - painted = self.compound(fills, &gs); + // A group in a compound path (`*u u … U *U`) only holds its paths. + let mut kids = ungrouped(kids); + // A compound clipping path keeps its subpaths' fill when they all paint alike. When they paint + // differently, the fills of the filled clipping ones are a compound path of their own, under what it clips. + let look = |n: &Node| (n.appearance.clone(), n.opacity, n.blend); + if clipping && kids.windows(2).any(|w| look(&w[0]) != look(&w[1])) { + let fills: Vec = kids + .iter_mut() + .filter(|k| matches!(k.kind, NodeKind::Path { clipping: true, .. })) + .filter_map(|k| self.fill_apart(k)) + .collect(); + painted = self.compound(fills, &gs); + } let Some(n) = self.compound(kids, &gs) else { if gs.hidden || !self.shown() { self.hidden_unread.extend(unread); @@ -228,9 +237,9 @@ impl<'a> Reader<'a> { /// Several clipping paths in a clipping group or a layer clip to their intersection, as each `W` reduces the clip /// set by the ones before it: each clipping path after the first clips a clipping group nested in the one before /// it, and the innermost one holds the art. As with one clipping path, every object in the group is clipped by all - /// of them, wherever it is written. A clipping path's painted fill stays at its path's level. In a compound path - /// (`union`), several clipping paths are one compound path. `at`: how deep the group or layer made of them is in the - /// document. + /// of them, wherever it is written. The fill of a compound clipping path read apart from it (`clip_fills`) stays at + /// its path's level. In a compound path (`union`), several clipping paths are one compound path. `at`: how deep the + /// group or layer made of them is in the document. fn clip_first(&mut self, kids: Vec, clips: &[usize], union: bool, at: usize) -> Result<(Vec>, bool), String> { if union || clips.len() < 2 { return Ok(self.clip_union(kids, clips)); @@ -238,7 +247,7 @@ impl<'a> Reader<'a> { let clips: std::collections::HashSet = clips.iter().copied().collect(); let (clip, rest): (Vec, Vec) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i)); let innermost = clip.len() - 1; - // A painted fill follows its clipping path; the art goes in the innermost level. + // A fill read apart follows its clipping path; the art goes in the innermost level. let level = |i: usize, n: &Node| { if self.clip_fills.contains(&n.id) { clip.iter().rposition(|(c, _)| *c < i).unwrap_or(0) } else { innermost } }; @@ -251,8 +260,7 @@ impl<'a> Reader<'a> { if at + deepest > MAX_DEPTH || (at + deepest > vectorcraft_doc::MAX_DEPTH && at + flat <= vectorcraft_doc::MAX_DEPTH) { return Err("its clipping masks are nested too deeply".into()); } - // Each level: its clipping path, the clipping paths' own fills, which paint under the art they clip, then the - // art. + // Each level: its clipping path, the fills read apart from it, which paint under the art they clip, then the art. let mut fills_at: Vec> = clip.iter().map(|_| Vec::new()).collect(); let mut art_at: Vec> = clip.iter().map(|_| Vec::new()).collect(); for (i, n) in rest { @@ -282,13 +290,18 @@ impl<'a> Reader<'a> { fn clip_union(&mut self, kids: Vec, clips: &[usize]) -> (Vec>, bool) { let clips: std::collections::HashSet = clips.iter().copied().collect(); let (mut clip, rest): (Vec, Vec) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i)); + let mut fills = Vec::new(); let clip_node = match clip.len() { 0 => None, 1 => clip.pop().map(|(_, n)| n), _ => { let rule = clip.first().map_or(FillRule::NonZero, |(_, n)| path_rule(n)); - // The compound path keeps the clipping paths' stroke, opacity and blend mode when they all have the same - // ones, and the knockout shape flag when they all have it: it has one appearance for all its paths. + // Each clipping path's fill is a path of its own, under the art it clips, as each one paints it. The + // compound path keeps the clipping paths' stroke, opacity and blend mode when they all have the same ones, + // and the knockout shape flag when they all have it: it has one appearance for all its paths. + for (_, n) in &mut clip { + fills.extend(self.fill_apart(n)); + } let shape = clip.iter().all(|(_, n)| n.knockout_shape); let look = |n: &Node| (n.appearance.clone(), n.opacity, n.blend); let shared = @@ -305,12 +318,66 @@ impl<'a> Reader<'a> { } }; let clips = clip_node.is_some(); - // A clipping path's own fill paints under the art it clips. - let (fills, rest): (Vec, Vec) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id)); - let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect(); + // A fill read apart paints under the art it clips. + let (apart, rest): (Vec, Vec) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id)); + let children = clip_node.into_iter().chain(fills).chain(apart.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect(); (children, clips) } + /// Clipping path `n`'s fills as a path of their own, taken off `n`; `None` when it has no fill. The path keeps `n`'s + /// transparency, visibility, lock and opacity mask, and none of its other attributes. + pub(super) fn fill_apart(&mut self, n: &mut Node) -> Option { + let is_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(_)); + if !n.appearance.items.iter().any(is_fill) { + return None; + } + let mut kind = n.kind.clone(); + if let NodeKind::Path { clipping, .. } = &mut kind { + *clipping = false; + } + let mut fill = Node::new(self.doc.alloc_id(), kind); + // The copies of a path read on `%_` lines are on those lines too, as `add` records them. + let hidden = self.hidden_ids.contains(&n.id); + if hidden { + self.hidden_ids.insert(fill.id); + } + // A compound clipping path's subpaths: copies with ids of their own, linked to the same swatches, that don't clip. + for c in fill.children_mut().into_iter().flatten() { + let mut copy = self.doc.reid(c); + self.reid_masks(&mut copy, 0); + if let NodeKind::Path { clipping, .. } = &mut copy.kind { + *clipping = false; + } + if hidden { + self.hidden_ids.insert(copy.id); + } + *c = Arc::new(copy); + } + (fill.opacity, fill.blend, fill.isolate, fill.knockout, fill.knockout_shape, fill.visible, fill.locked) = + (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape, n.visible, n.locked); + // The opacity mask's art is a copy with ids of its own, linked to the same swatches. + if let Some(mut mask) = n.extras().mask.clone() { + let mut art = self.doc.reid(&mask.art); + self.reid_masks(&mut art, 0); + mask.art = Arc::new(art); + fill.extras_mut().mask = Some(mask); + } + let (mut fills, mut rest) = ((*n.appearance).clone(), (*n.appearance).clone()); + fills.items.retain(is_fill); + rest.items.retain(|i| !is_fill(i)); + fill.appearance = fills.into(); + n.appearance = rest.into(); + // The new path, its subpaths and their points count toward the objects read, as a multi-layer mask's copies do. + let mut weight = fill.count_with_masks(); + fill.walk(&mut |p| { + if let NodeKind::Path { path, .. } = &p.kind { + weight = weight.saturating_add(path.anchor_count()); + } + }); + self.nodes = self.nodes.saturating_add(weight); + Some(fill) + } + pub(super) fn end_layer(&mut self) -> Result<(), String> { self.pending_unread_fill = false; self.pending_unread_stroke = false; diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 33af13372..45f60916b 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -7,7 +7,7 @@ use super::types::{ FLIP, GState, Kind, MAX_DEPTH, MAX_HEX, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS, PatternHead, Place, Raster, SKIPPED, SymbolHead, VERSIONED_UNENDED, }; -use super::{Arc, Global, Lexer, Node, NodeKind, PatternDef, Reader, Rect, Tok, V, marker, obj}; +use super::{Arc, BrushArtColors, Global, Lexer, Node, NodeKind, PatternDef, Reader, Rect, Tok, V, marker, obj}; use vectorcraft_doc::{NodeId, Symbol, SymbolRegistration}; impl<'a> Reader<'a> { @@ -648,10 +648,10 @@ impl<'a> Reader<'a> { named } - /// Give the art of the opacity masks in `n` (a copy of a symbol's art with ids of this + /// Give the art of the opacity masks in `n` (a copy of a symbol's art or of a clipping path with ids of this /// document, [`vectorcraft_doc::Document::reid`], which leaves masks as they are) ids of this /// document too. - fn reid_masks(&mut self, n: &mut Node, depth: usize) { + pub(super) fn reid_masks(&mut self, n: &mut Node, depth: usize) { if depth > MAX_DEPTH { return; } @@ -714,7 +714,10 @@ impl<'a> Reader<'a> { /// A brush's art (`section`: what follows `%AI8_BeginBrushPattern`, its name as a string, then /// the art), read by a reader of its own as a pattern's is, in a space with y down. Art it can't - /// read whole isn't kept: the brushes that use it aren't read. + /// read whole isn't kept: the brushes that use it aren't read. Kept art's colors are kept with + /// it, and count as the page's once a brush that is kept uses the art + /// ([`Reader::count_brush_art_colors`]): each name's first definition in the art, at most + /// [`brush::MAX_BRUSH_ART_COLORS`] names. fn define_brush_art(&mut self, section: &[u8]) { if self.brush_art_sections >= brush::MAX_BRUSH_ART || self.subreader_depth >= MAX_SUBREADERS { return; @@ -758,9 +761,13 @@ impl<'a> Reader<'a> { let node = match art.len() { 1 => art.pop().map(Arc::unwrap_or_clone), _ => Some(Node::group(self.doc.alloc_id(), art)), - }; + } + .map(brush::without_frame); let Some(node) = node else { return }; - self.brush_art.insert(name, node); + self.brush_art.insert(name.clone(), node); + let mut colors = BrushArtColors { spots: sub.spots, stop_spots: sub.stop_spots, cmyk: sub.cmyk_colors, rgb: sub.rgb_colors }; + colors.keep_first(brush::MAX_BRUSH_ART_COLORS); + self.brush_art_colors.insert(name, colors); } /// Lend `sub`, a reader of a definition, what this import may still spend on laying out blends diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 65d949f0f..1cbadc044 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -149,15 +149,7 @@ pub fn artboards(data: &[u8]) -> (Vec<(String, Rect)>, Option) { } // Reading can stop before the artboards (art kept in the document's data comes first), or // between them and the active artboard's entry: read their array on its own. - let Some((array, active)) = artboard_array(data) else { return found }; - let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec(); - alone.extend_from_slice(array); - if let Some(active) = active { - alone.push(b'\n'); - alone.extend_from_slice(active); - } - alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n"); - let (boards, active) = artboards_read(&alone); + let Some((boards, active)) = artboard_array_read(data) else { return found }; if found.0.is_empty() { (boards, active) } else if boards == found.0 { @@ -168,6 +160,29 @@ pub fn artboards(data: &[u8]) -> (Vec<(String, Rect)>, Option) { } } +/// The index of the active artboard (`CropAreaActive`) in the `ArtboardArray` of the editing data +/// `data`, as [`artboards`] reads it, from that array and the entry beside it alone. +pub fn active_artboard(data: &[u8]) -> Option { + artboard_array_read(data)?.1 +} + +/// Artboards as [`artboards`] gives them: names and rects in art space, and the active one's index. +type ArtboardsRead = (Vec<(String, Rect)>, Option); + +/// The `ArtboardArray` entry of the editing data `data` ([`artboard_array`]), with the +/// `CropAreaActive` entry beside it, read on their own as [`artboards_read`] reads them. +fn artboard_array_read(data: &[u8]) -> Option { + let (array, active) = artboard_array(data)?; + let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec(); + alone.extend_from_slice(array); + if let Some(active) = active { + alone.push(b'\n'); + alone.extend_from_slice(active); + } + alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n"); + Some(artboards_read(&alone)) +} + /// The key that ends the `ArtboardArray` entry of the editing data. const ARTBOARD_ARRAY: &[u8] = b"%_; (ArtboardArray) ,"; @@ -931,6 +946,41 @@ impl<'a> Reader<'a> { Some((lib, left_out + self.brushes_unread)) } + /// Count the colors of the brush art that the brushes `lib` use after the colors read so far, + /// in the order the art was read: its spot colors make swatches, and its colors count toward + /// the color mode. Art that no brush of `lib` uses brings none of its colors. The art brings + /// at most [`brush::MAX_BRUSH_ART_COLORS`] different color names in all, each name's color + /// operators before its gradient stops, as [`Self::file_swatches`] takes them; its colors past + /// them still count toward the color mode. + fn count_brush_art_colors(&mut self, lib: &[vectorcraft_brush::Brush]) { + let used: std::collections::HashSet = lib.iter().flat_map(|b| b.art()).map(|a| a.id).collect(); + let mut names: Vec<(NodeId, String)> = self.brush_art.iter().filter(|(_, a)| used.contains(&a.id)).map(|(n, a)| (a.id, n.clone())).collect(); + names.sort(); + // The names brought so far: every section's color operators', then every section's stops'. + let mut brought = std::collections::HashSet::new(); + let mut stops = vec![]; + for (_, name) in names { + let Some(colors) = self.brush_art_colors.remove(&name) else { continue }; + for n in colors.spots { + if brought.contains(&n.name) || brought.len() >= brush::MAX_BRUSH_ART_COLORS { + continue; + } + brought.insert(n.name.clone()); + self.spots.push(n); + } + stops.push(colors.stop_spots); + self.cmyk_colors += colors.cmyk; + self.rgb_colors += colors.rgb; + } + for n in stops.into_iter().flatten() { + if brought.contains(&n.name) || brought.len() >= brush::MAX_BRUSH_ART_COLORS { + continue; + } + brought.insert(n.name.clone()); + self.stop_spots.push(n); + } + } + /// The graphic styles of the style list, in its order: each listed name the art styles define with /// an appearance [`appearance_from_art_style`] reads, once (a name listed again, empty or not /// defined is left out), with ids from 1. Also the id of each kept name. @@ -982,6 +1032,8 @@ impl<'a> Reader<'a> { } let to_doc = self.ensure_space(); let artboard = self.first_board(); + // Before the artboards are taken below: the page is the first artboard's. + let page = self.page_in_doc(); self.check_symbols(); self.order_symbols(); drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); @@ -1041,6 +1093,10 @@ impl<'a> Reader<'a> { }; take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0); } + let mut brushes = self.brush_library(); + if let Some((lib, _)) = &brushes { + self.count_brush_art_colors(lib); + } if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) { doc.color_mode = ColorMode::Cmyk; let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); @@ -1061,7 +1117,6 @@ impl<'a> Reader<'a> { if let Some(author) = self.author.take() { doc.metadata.author = author; } - let mut brushes = self.brush_library(); // The file's graphic styles in place of the defaults, and the objects linked to them. let (styles, ids) = self.graphic_styles(); let styles_read = !styles.is_empty(); @@ -1086,6 +1141,9 @@ impl<'a> Reader<'a> { for a in brushes.iter_mut().flat_map(|(lib, _)| lib.iter_mut().flat_map(|b| b.art_mut())) { vectorcraft_doc::swatches::map_node_solid_paints(a, &mut follow); } + for w in &mut self.warped_type { + vectorcraft_doc::swatches::map_node_solid_paints(&mut w.look, &mut follow); + } } let mut warnings = self.warnings; if let Some((lib, unread)) = brushes { @@ -1126,6 +1184,12 @@ impl<'a> Reader<'a> { placeholders: self.placeholders, placed_elsewhere: self.placed_elsewhere, envelopes_drawn: self.envelopes_drawn, + warped_type: super::super::envelope::WarpedType { + looks: self.warped_type, + checks: self.envelope_checks, + spent: self.envelope_spent, + page, + }, styles_read, canvas_scale: self.canvas_scale, xml_ids: self.xml_ids, diff --git a/crates/eps/src/import/ai/read/masks.rs b/crates/eps/src/import/ai/read/masks.rs index c07ab5868..a7e8356bd 100644 --- a/crates/eps/src/import/ai/read/masks.rs +++ b/crates/eps/src/import/ai/read/masks.rs @@ -57,32 +57,49 @@ impl Reader<'_> { /// `Md`: the clipping path painted last since `Mb`, in the same group or layer, is the mask. fn define_multi_mask(&mut self) -> Result<(), String> { let depth = self.frames.len(); - let Some(m) = self.multi_mask.as_mut() else { return Err("a multi-layer mask's operators are out of order".into()) }; + let Some(m) = self.multi_mask.as_ref() else { return Err("a multi-layer mask's operators are out of order".into()) }; if m.mask.is_some() || depth == 0 { return Err("a multi-layer mask's operators are out of order".into()); } if m.depth != depth { return Err("a group crosses a multi-layer mask".into()); } + let start = m.start; let Some(f) = self.frames.last_mut() else { return Err("a multi-layer mask's operators are out of order".into()) }; - let Some(at) = f.clips.iter().copied().filter(|i| *i >= m.start && *i < f.children.len()).max() else { + let Some(at) = f.clips.iter().copied().filter(|i| *i >= start && *i < f.children.len()).max() else { return Err("a multi-layer mask needs one clipping path".into()); }; - let mask = f.children.remove(at); + let mut mask = f.children.remove(at); f.clips.retain(|i| *i != at); for i in &mut f.clips { if *i > at { *i -= 1; } } + // The mask's fill paints once, where its path is, under the objects masked after it: it stays a path of its own + // among them, and the copies of the mask in each layer it covers have no fill. + if let Some(fill) = self.fill_apart(&mut mask) { + if self.nodes > MAX_NODES { + return Err(format!("it has more than {MAX_NODES} objects")); + } + let Some(f) = self.frames.last_mut() else { return Err("a multi-layer mask's operators are out of order".into()) }; + f.children.insert(at, fill); + for i in &mut f.clips { + if *i >= at { + *i += 1; + } + } + } let mut weight = mask.count_with_masks(); mask.walk(&mut |n| { if let NodeKind::Path { path, .. } = &n.kind { weight = weight.saturating_add(path.anchor_count()); } }); - m.weight = weight; - m.mask = Some(mask); + if let Some(m) = self.multi_mask.as_mut() { + m.weight = weight; + m.mask = Some(mask); + } self.after_object = false; Ok(()) } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9535aa062..3d95c9485 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -81,8 +81,8 @@ impl<'a> Reader<'a> { /// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in /// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with /// an identifier or string longer than [`MAX_TAG_TEXT`] bytes is ignored, and so are tags in a - /// pattern's or brush's art, which is not an object of the document. A clipping path's tags are - /// its own, not those of the copy that paints its fill. + /// pattern's or brush's art, which is not an object of the document. A compound clipping path's tags are its own, + /// not those of the compound path that paints its fill apart from it. fn object_tag(&mut self, vals: &[V], hidden: bool) { let [.., V::Name(id), V::Str(value)] = vals else { self.note_unknown("XT", hidden); @@ -360,13 +360,13 @@ impl<'a> Reader<'a> { "TO" => self.end_text(hidden)?, // `(text) justified TS`, special characters (§22.6): the reader adds its characters like `Tx`. "Tx" | "Tj" | "TX" | "TS" => self.text_body(w, &vals), - "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "TV" | "Tv" | "Ty" | "TY" | "Xb" | "Xe" - | "XB" => { + "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "TV" | "Tv" | "Ty" | "TY" | "Tg" | "Xb" + | "Xe" | "XB" => { self.text_attr(w, &vals); } - // Attributes the reader doesn't keep (kerning pairs, line breaking, binding punctuation - // and Japanese layout rules) and final-form positions (§15.3). - "Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TG" | "Tg" | "Tu" | "Xu" => {} + // Attributes the reader doesn't keep (kerning pairs, line breaking and Japanese layout + // rules) and final-form positions (§15.3). + "Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TG" | "Tu" | "Xu" => {} // `TM` (pop text matrix) and `Te` (end render), listed in §22.6, keep nothing either; the // reader takes `Te` with any number of operands. "TM" | "Te" => {} @@ -706,6 +706,7 @@ impl<'a> Reader<'a> { self.looks.insert(look.id); } // The type that a text object's look draws is the object's own, whatever comes of its style. + let mut look_drew_type = false; if after && let Some(f) = self.frames.last_mut() && let [.., look, object] = f.children.as_mut_slice() @@ -715,7 +716,7 @@ impl<'a> Reader<'a> { && self.hidden_ids.contains(&look.id) == hidden && matches!(look.kind, NodeKind::Group { clip: false, .. }) { - strip_look_type(look); + look_drew_type = strip_look_type(look); } if code != 1.0 || style.is_empty() || !after { return; @@ -761,9 +762,25 @@ impl<'a> Reader<'a> { } let Some(mut object) = f.children.pop() else { return }; let Some(look) = f.children.pop() else { return }; - if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) { + let left_out = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible); + if let Some(b) = left_out { self.restyled.push(b); } + // Warped type is checked against its look once it is laid out. The look stands in for + // the type only when it holds all that the object draws: not when it draws nothing or + // draws type of its own. + if slot_of(object.name.as_deref()).is_some() && crate::import::ai::envelope::warps(&appearance) && self.warped_type.len() < MAX_NODES { + let mut saved = look.clone(); + // When the object itself is on `%_` lines, every child of its look is too, so the + // look's own copies can't be told apart from the rest. + if !hidden { + strip(&mut saved, &self.hidden_ids); + } + if !look_drew_type && !holds_type(&saved) && saved.visual_bounds().is_some() && draws_art(&saved, 0) { + take_attributes(&mut saved, &object); + self.warped_type.push(crate::import::ai::envelope::WarpedLook { slot: object.id, look: saved, left_out }); + } + } object.appearance = appearance.into(); // The object's transparency is written on its look, after the look's group ends. (object.opacity, object.blend, object.isolate, object.knockout, object.knockout_shape) = @@ -814,29 +831,47 @@ impl<'a> Reader<'a> { look.name = object.name.clone().or(look.name.take()); look.visible = object.visible; look.locked = object.locked; - // The object's note, Show Center, URL and data (object tags) stay with the look that stands - // for it, in place of the look's own where the object has them. - if let Some(a) = object.extras().attrs.as_deref().cloned() { - look.edit_attrs(|l| { - if a.show_center.is_some() { - l.show_center = a.show_center; - } - if !a.url.is_empty() { - l.url = a.url; - } - if !a.note.is_empty() { - l.note = a.note; - } - let room = MAX_TAGS.saturating_sub(l.data.len()); - l.data.extend(a.data.into_iter().take(room)); - }); - } + take_attributes(look, &object); if !hidden { self.drawn_looks += 1; } } } +/// Does `n` or anything shown in it (to [`MAX_DEPTH`]) paint? Any object other than a path, a +/// compound path or a group counts: it draws more than its fills and strokes. +fn draws_art(n: &Node, depth: usize) -> bool { + depth > MAX_DEPTH + || !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) + || n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) + || n.children().into_iter().flatten().any(|c| c.visible && draws_art(c, depth + 1)) +} + +/// Is `n` or anything in it a text slot? +fn holds_type(n: &Node) -> bool { + slot_of(n.name.as_deref()).is_some() || n.children().into_iter().flatten().any(|c| holds_type(c)) +} + +/// The note, Show Center, URL and data (object tags) of `object` stay with the `look` that stands +/// for it, in place of the look's own where the object has them. +fn take_attributes(look: &mut Node, object: &Node) { + if let Some(a) = object.extras().attrs.as_deref().cloned() { + look.edit_attrs(|l| { + if a.show_center.is_some() { + l.show_center = a.show_center; + } + if !a.url.is_empty() { + l.url = a.url; + } + if !a.note.is_empty() { + l.note = a.note; + } + let room = MAX_TAGS.saturating_sub(l.data.len()); + l.data.extend(a.data.into_iter().take(room)); + }); + } +} + /// Whether `look`'s paths don't all paint the same. pub(super) fn paints_apart(look: &Node) -> bool { fn leaves<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { @@ -1121,6 +1156,37 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) Some(Appearance { items, effects: st.effects.clone(), contents_index: st.contents_index }) } +/// What the reader makes of art style `name` in `section` (the text between `%AI9_BeginArtStyles` +/// and its end), for writers that check a style before they write it. +#[derive(Clone, Debug)] +pub(crate) struct StyleRead { + /// The style's appearance: a gradient fill is the object's own gradient. + pub appearance: Appearance, + /// An object other than type is read with it ([`crate::import::ai::style::ArtStyle::simple`]). + pub simple: bool, + /// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with. + pub pathfinder: bool, +} + +/// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], +/// with a style's one gradient fill as `own_gradient`, the object's own gradient, as [`styled`] reads +/// it on objects other than type. `None` when the section doesn't define it, the reader doesn't read +/// it, or it paints with a pattern, a brush or a gradient that isn't the object's own. +pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&Paint>) -> Option { + use crate::import::ai::style::StyleItem; + let mut st = crate::import::ai::style::art_styles(section).remove(name)?; + if st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count() == 1 { + let own = own_gradient?; + for item in &mut st.items { + if matches!(item, StyleItem::Gradient { .. }) { + *item = StyleItem::Fill(own.clone()); + } + } + } + let appearance = appearance_from_art_style(&st)?; + Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder }) +} + /// A style's stroke as an appearance item (`None` for a fill). fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option { let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None }; @@ -1138,14 +1204,19 @@ fn with_look(layer: &mut AppearanceItem, look: &crate::import::ai::style::ItemLo } } -/// Remove from `n` the text slots of the type its look draws ([`super::is_look_slot`]), in its groups too. -fn strip_look_type(n: &mut Node) { +/// Remove from `n` the text slots of the type its look draws ([`super::is_look_slot`]), in its groups +/// too. Returns whether there were any. +fn strip_look_type(n: &mut Node) -> bool { + let mut any = false; if let Some(children) = n.children_mut() { + let before = children.len(); children.retain(|c| !super::is_look_slot(c.name.as_deref())); + any = children.len() != before; for c in children.iter_mut().filter(|c| c.children().is_some_and(|k| !k.is_empty())) { - strip_look_type(Arc::make_mut(c)); + any |= strip_look_type(Arc::make_mut(c)); } } + any } /// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 17f843d9a..8b9607d18 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -1,4 +1,4 @@ -use super::{Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Node, Obj, Paint, Rect, V}; +use super::{Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Named, Node, Obj, Paint, Rect, V}; /// Deepest nesting of layers, groups and dictionaries. pub(super) const MAX_DEPTH: usize = 256; @@ -41,7 +41,7 @@ pub(crate) const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); pub(super) const MAX_PATTERNS: usize = 4096; /// Most symbol definitions read (instances of others come in as the page draws them). -pub(super) const MAX_SYMBOLS: usize = 4096; +pub(crate) const MAX_SYMBOLS: usize = 4096; /// Most names a symbol list orders. pub(super) const MAX_LISTED: usize = 4 * MAX_SYMBOLS; @@ -249,6 +249,29 @@ pub(super) struct SymbolHead { pub(super) missing_stroke: bool, } +/// The colors a brush's art uses, counted as the page's are once a brush that is kept uses the art. +#[derive(Debug, Default)] +pub(super) struct BrushArtColors { + /// The named colors its color operators define. + pub(super) spots: Vec, + /// The named colors its gradient stops name. + pub(super) stop_spots: Vec, + /// Its colors set in each model. + pub(super) cmyk: usize, + pub(super) rgb: usize, +} + +impl BrushArtColors { + /// Keep each name's first definition only, its color operators' before its gradient stops', + /// and at most `limit` names in all. The process-color counts stay as they are. + pub(super) fn keep_first(&mut self, limit: usize) { + let mut seen = std::collections::HashSet::new(); + let mut keep = |n: &Named| seen.len() < limit && seen.insert(n.name.clone()); + self.spots.retain(|n| keep(n)); + self.stop_spots.retain(|n| keep(n)); + } +} + /// An image's operands, from `XN` and before `XI`. #[derive(Debug, Default)] pub(super) struct Raster { diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index bdb9a3d6e..da9a83ad7 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1186,3 +1186,32 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() { format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); assert!(!art_styles(data.as_bytes())["Odd"].plain); } + +/// A style as a writer checks it: the reader's appearance, with a gradient fill as the object's own, +/// and nothing for styles it doesn't read. +#[test] +fn a_style_is_read_as_a_writer_checks_it() { + let read = |data: &str, own: Option<&Paint>| crate::import::read_art_style(data.as_bytes(), "Odd", own); + let style = |kids: &[String]| format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", kids)); + let gradient = fill("Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB"); + let stack = compound("Stack Style Filter", std::slice::from_ref(&gradient)); + let data = style(&[stack, blend(1, 0.5)]); + let own = Paint::solid(vectorcraft_color::Color::WHITE); + let got = read(&data, Some(&own)).unwrap(); + assert_eq!(got.appearance.items.len(), 1); + assert_eq!(got.appearance.items[0].paint(), &own); + assert!(got.simple && !got.pathfinder); + // A gradient that isn't the object's own, a pattern, another name, two gradients: nothing. + assert!(read(&data, None).is_none()); + let pattern = style(&[compound("Stack Style Filter", &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p")])]); + assert!(read(&pattern, None).is_none()); + assert!(crate::import::read_art_style(data.as_bytes(), "Even", Some(&own)).is_none()); + assert!(read(&style(&[compound("Stack Style Filter", &[gradient.clone(), gradient])]), Some(&own)).is_none()); + // Damaged text: every cut of the style, and stray bytes, without a panic. + for cut in 0..data.len() { + let _ = read(data.get(..cut).unwrap_or_default(), Some(&own)); + } + for junk in ["(", ")", ";", "/KnownStyle :\n(Odd) /Name ,\n;;;;", "/KnownStyle :\n(Odd) /Name ,\n/Array : 1 /Real , ; (x) ,"] { + let _ = read(junk, Some(&own)); + } +} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 36a93a690..e227ec12b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -187,9 +187,9 @@ fn a_note_before_a_group_or_compound_path_is_not_theirs() { } } -/// A clipping path's note is its own, not that of the copy that paints its fill. +/// A filled clipping path keeps its note, and no other object takes it. #[test] -fn a_clipping_paths_note_is_not_copied_to_its_painted_fill() { +fn a_filled_clipping_paths_note_is_its_own() { let body = format!("0 Ae\nq\n{RED}%AI3_Note:clip\n{}W\nf\n%AI3_Note:\n{}f\nQ\n", rect_path(5.0, 5.0, 25.0, 25.0), rect_path(0.0, 0.0, 30.0, 30.0)); let doc = read_ok(&layer(&body)).doc; @@ -203,7 +203,7 @@ fn a_clipping_paths_note_is_not_copied_to_its_painted_fill() { walk(&art(&doc), &mut all); let noted: Vec<_> = all.iter().filter(|(_, n)| n == "clip").collect(); assert_eq!(noted, [&(true, "clip".to_string())], "{all:?}"); - assert!(all.len() >= 3, "the painted copy is there: {all:?}"); + assert_eq!(all.len(), 3, "the group, its clipping path and the art: {all:?}"); } /// `1 Ap` shows the center point of the paths after it until `0 Ap` (§12, showCenter), which leaves @@ -294,8 +294,8 @@ fn object_tags_without_an_object_before_them_tag_nothing() { assert!(err.contains("operators VectorCraft doesn't know"), "{err}"); } -/// A clipping path's tags are its own, not those of the copy that paints its fill, and a clip -/// group's tags after `Q` are the group's. One object keeps at most 64 tags in its data. +/// A filled clipping path's tags are its own, and a clip group's tags after `Q` are the group's. +/// One object keeps at most 64 tags in its data. #[test] fn object_tags_on_clipping_paths_and_the_tag_limit() { let body = format!( @@ -307,6 +307,7 @@ fn object_tags_on_clipping_paths_and_the_tag_limit() { let top = &art(&doc); assert_eq!(top.len(), 1); assert_eq!(tags(&top[0]).1, [tag("group", "clipped")]); + assert_eq!(top[0].children().unwrap().len(), 2, "the clipping path and the art"); let tagged: Vec<_> = top[0].children().unwrap().iter().filter(|k| !tags(k).1.is_empty()).map(|k| k.kind.clone()).collect(); assert_eq!(tagged.len(), 1, "{tagged:?}"); assert!(matches!(tagged[0], NodeKind::Path { clipping: true, .. }), "{tagged:?}"); @@ -374,6 +375,28 @@ fn a_drawn_look_keeps_its_objects_tags() { assert_eq!(tags(&a[0]), ("https://example.com/n".to_string(), vec![tag("own", "L"), tag("sku", "N-1")])); } +/// A clipping subpath on `%_` lines in a drawn look leaves with the fill read apart from it. +#[test] +fn a_hidden_clipping_paths_fill_leaves_the_look_with_it() { + let (a, b, c) = (rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0), rect_path(0.0, 0.0, 10.0, 10.0)); + let two = rect_path(8.0, 8.0, 20.0, 20.0); + let second: String = format!("0 0 1 0 0 0 1 Xa\n{two}h\nW\nf\n").lines().map(|l| format!("%_{l}\n")).collect(); + let hidden_clip = format!("*u\n{RED}{a}h\nW\nf\n{second}*U\n"); + let body = format!("0 Ae\nu\nu\n{hidden_clip}{b}f\nU\nU\n0 0 Xd\n6 () XW\n%_{}n\n1 (Gone style) XW\n", c.replace('\n', "\n%_")); + let doc = read_ok(&layer(&body)).doc; + let mut leaves = vec![]; + doc.layers.iter().for_each(|l| { + l.walk(&mut |n| { + if let NodeKind::Path { clipping, .. } = &n.kind { + leaves.push((*clipping, fill(n).color())); + } + }) + }); + // The first subpath, its fill and the look's plain path stay; the second subpath and its fill leave. + let (red, blue) = (Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(leaves, [(true, None), (false, red), (false, blue)]); +} + /// Tags in a brush's art don't keep it from being read, and stay out of the art: it isn't an object /// of the document. #[test] @@ -1245,20 +1268,76 @@ fn compound_paths_and_clipping() { // The clipping path goes first. assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); assert!(matches!(children[1].kind, NodeKind::Path { clipping: false, .. })); - // A clipping path with a fill paints it too, under the art it clips; without one, nothing. + // A clipping path with a fill keeps it, and paints it under the art it clips (`Node::clip_paint`); without one, + // nothing. + let blue = Some(Color::rgb(0.0, 0.0, 1.0)); let filled = format!("0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\nQ\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0)); let doc = read_ok(&layer(&filled)).doc; let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") }; let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect(); - assert_eq!(kinds, [(true, None), (false, Some(Color::rgb(0.0, 0.0, 1.0))), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]); + assert_eq!(kinds, [(true, blue), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]); + assert_eq!(children[0].clip_paint().fill.map(|f| fill(&f).color()), Some(blue)); // A clipping path in a layer clips the layer. let doc = read_ok(&layer(&format!("{}f\n{}W\nn\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc; let NodeKind::Layer { clip, children, .. } = &doc.layers[0].kind else { panic!() }; assert!(*clip && matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); + // A filled clipping path in a layer keeps its fill too. + let doc = read_ok(&layer(&format!("{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc; + let NodeKind::Layer { clip: true, children, .. } = &doc.layers[0].kind else { panic!("{:?}", doc.layers[0].kind) }; + let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect(); + assert_eq!(kinds, [(true, blue), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]); + // A compound clipping path keeps its subpaths' fill. + let compound = format!( + "0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n*u\n{}h\nW\nf\n{}h\nW\nf\n*U\nQ\n", + rect_path(0.0, 0.0, 30.0, 30.0), + rect_path(5.0, 5.0, 25.0, 25.0), + rect_path(10.0, 10.0, 20.0, 20.0) + ); + let doc = read_ok(&layer(&compound)).doc; + let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") }; + assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children.len() == 2, "{children:?}"); + assert_eq!((fill(&children[0]).color(), fill(&children[1]).color()), (blue, Some(Color::rgb(1.0, 0.0, 0.0)))); + // One subpath filled and a later one stroked: the compound clipping path keeps the stroke, and the filled subpath's + // fill is a compound path of its own under the art, so the stroked subpath stays unfilled. + let mixed = format!( + "0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n0 1 0 0 0 1 0 XA\n3 w\n*u\n{}h\nW\nf\n{}h\nW\nS\n*U\nQ\n", + rect_path(0.0, 0.0, 30.0, 30.0), + rect_path(5.0, 5.0, 25.0, 25.0), + rect_path(10.0, 10.0, 20.0, 20.0) + ); + let doc = read_ok(&layer(&mixed)).doc; + let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") }; + assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children.len() == 3, "{children:?}"); + let stroke = children[0].appearance.stroke().map(|s| (s.paint.color(), s.width)); + assert_eq!((fill(&children[0]).color(), stroke), (None, Some((Some(Color::rgb(0.0, 1.0, 0.0)), 3.0)))); + let NodeKind::Compound { children: filled, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) }; + assert_eq!(filled.len(), 1, "only the filled subpath: {filled:?}"); + assert!(filled.iter().all(|p| matches!(p.kind, NodeKind::Path { clipping: false, .. })), "{filled:?}"); + assert_eq!((fill(&children[1]).color(), children[1].appearance.stroke().is_none()), (blue, true)); + assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); + // A subpath that doesn't clip keeps its fill; only the clipping subpath's fill is read apart. + let green = Some(Color::rgb(0.0, 1.0, 0.0)); + let plain = format!( + "0 Ae\nq\n{RED}{}f\n*u\n0 0 1 0 0 0 1 Xa\n{}f\n0 1 0 0 0 1 0 Xa\n{}h\nW\nf\n*U\nQ\n", + rect_path(0.0, 0.0, 30.0, 30.0), + rect_path(5.0, 5.0, 25.0, 25.0), + rect_path(10.0, 10.0, 20.0, 20.0) + ); + let doc = read_ok(&layer(&plain)).doc; + let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") }; + let NodeKind::Compound { children: subpaths, .. } = &children[0].kind else { panic!("{:?}", children[0].kind) }; + let kinds: Vec<_> = subpaths.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect(); + assert_eq!(kinds, [(false, blue), (true, None)]); + let NodeKind::Compound { children: apart, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) }; + assert_eq!((apart.len(), fill(&children[1]).color()), (1, green), "only the clipping subpath's fill: {apart:?}"); + // A tag after the compound clipping path is its own. + let doc = read_ok(&layer(&mixed.replace("*U\n", "*U\n/edge (clip) XT\n"))).doc; + let NodeKind::Group { children, .. } = &art(&doc)[0].kind else { panic!("a clipping group") }; + assert_eq!((tags(&children[0]).1, tags(&children[1]).1), (vec![tag("edge", "clip")], vec![])); } -/// A stroked clipping path (`W S`, `W B`, or a gradient fill with `1 BB`) keeps its stroke on the clipping path, and a -/// fill goes on the painted copy under the clipped art. +/// A stroked clipping path (`W S`, `W B`, or a gradient fill with `1 BB`) keeps its stroke and its fill on the clipping +/// path. #[test] fn a_clipping_paths_stroke_stays_on_the_clipping_path() { let blue = Some(Color::rgb(0.0, 0.0, 1.0)); @@ -1279,17 +1358,11 @@ fn a_clipping_paths_stroke_stays_on_the_clipping_path() { let stroked = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nS\nQ\n"); assert_eq!(children(&read_ok(&layer(&stroked)).doc), [(true, None, false, Some((blue, 3.0))), (false, red, false, None)]); let both = format!("0 Ae\nq\n{RED}{under}f\n0 1 0 0 0 1 0 Xa\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nB\nQ\n"); - assert_eq!( - children(&read_ok(&layer(&both)).doc), - [(true, None, false, Some((blue, 3.0))), (false, Some(Color::rgb(0.0, 1.0, 0.0)), false, None), (false, red, false, None)] - ); + assert_eq!(children(&read_ok(&layer(&both)).doc), [(true, Some(Color::rgb(0.0, 1.0, 0.0)), false, Some((blue, 3.0))), (false, red, false, None)]); let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n0 0 0 0 1 0 0 2 0 6 %_BS\n%_0 0 0 0 1 0 0 2 0 6 0 0 Bs\n\ %_0 0 0 0 0 0 1 2 1 6 100 50 Bs\nBD\n%AI5_EndGradient\n"; let gradient = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nBb\n0 0 0 0 Bh\n1 (G) 5 5 0 20 1 0 0 1 0 0 1 Bg\nf\n1 BB\nQ\n"); - assert_eq!( - children(&read_ok(&format!("{defs}{}", layer(&gradient))).doc), - [(true, None, false, Some((blue, 3.0))), (false, None, true, None), (false, red, false, None)] - ); + assert_eq!(children(&read_ok(&format!("{defs}{}", layer(&gradient))).doc), [(true, None, true, Some((blue, 3.0))), (false, red, false, None)]); // Several clipping paths each keep their own stroke, opacity and blend mode: the second one clips a clipping group // inside the first one's. let inner = rect_path(10.0, 10.0, 20.0, 20.0); @@ -1331,8 +1404,7 @@ fn the_compound_of_clipping_paths_keeps_their_knockout_shape() { /// Several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection: each `W` /// reduces the clip set by the ones before it (AI7 spec §5.6.2). Each clipping path after the first clips a clipping -/// group inside the one before it, and the innermost one holds the art; a clipping path's painted fill stays at its own -/// level. +/// group inside the one before it, and the innermost one holds the art; a filled clipping path keeps its fill. #[test] fn several_clipping_paths_clip_to_their_intersection() { let square = rect_path(0.0, 0.0, 80.0, 80.0); @@ -1364,11 +1436,12 @@ fn several_clipping_paths_clip_to_their_intersection() { assert_eq!((two.len(), two[0].len()), (2, 2), "{two:?}"); assert_eq!(two[0][0], (true, None, ra)); assert_eq!(two[1], [(true, None, rb), (false, red, r(0.0, 0.0, 80.0, 80.0))]); - // Three clipping paths nest twice; a filled clipping path's painted copy is clipped by the paths before it only. + // Three clipping paths nest twice; a filled clipping path keeps its fill at its own level, so the fill is clipped + // by that path and the paths before it only. let three = group(&format!("0 Ae\nq\n{a}h\nW\nn\n0 0 1 0 0 0 1 Xa\n{b}h\nW\nf\n{}h\nW\nn\n{RED}{square}f\nQ\n", rect_path(30.0, 10.0, 50.0, 90.0))); assert_eq!(three.len(), 3, "{three:?}"); - assert_eq!(three[1][..2], [(true, None, rb), (false, blue, rb)]); + assert_eq!((three[1].len(), three[1][0]), (2, (true, blue, rb)), "{three:?}"); assert_eq!(three[2], [(true, None, r(30.0, 10.0, 50.0, 90.0)), (false, red, r(0.0, 0.0, 80.0, 80.0))]); // As with one clipping path, every object in the group is clipped by all of them, in the order written, wherever // it is written. @@ -1766,6 +1839,61 @@ fn a_compound_path_holding_its_drawn_look_paints_its_paths_apart() { assert_eq!(fills, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{a:#?}"); } +/// Clipping paths in a compound path that holds its drawn look clip as one compound path. Each one's fill is a path of +/// its own under the art they clip, with the clipping path's opacity mask, so overlapping fills and fills that differ +/// paint as each clipping path paints them. +#[test] +fn clipping_paths_beside_a_compound_paths_look_keep_their_fills() { + let (a, b) = (rect_path(0.0, 0.0, 20.0, 20.0), rect_path(5.0, 5.0, 25.0, 25.0)); + let look = format!("0 Ae\nu\n{RED}{}f\n0 0 0 0 0 0 1 Xa\n{}f\nU\n9 () XW\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(10.0, 10.0, 15.0, 15.0)); + let mask_art: String = + format!("0 0 0 0 (Global white) 1 0 Xk\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)).lines().map(|l| format!("%_{l}\n")).collect(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let children = |second: &str, after_first: &str| { + let body = format!("0 Ae\n*u\n{RED}{a}h\nW\nf\n{after_first}{second}{b}h\nW\nf\n{look}*U\n"); + let doc = read_ok(&layer(&body)).doc; + // Every object, opacity masks' art included, has an id of its own. + fn ids(n: &Node, seen: &mut std::collections::HashSet) { + assert!(seen.insert(n.id), "{:?} appears twice", n.id); + if let Some(m) = &n.extras().mask { + ids(&m.art, seen); + } + for c in n.children().into_iter().flatten() { + ids(c, seen); + } + } + let mut seen = std::collections::HashSet::new(); + doc.layers.iter().for_each(|l| ids(l, &mut seen)); + let a = art(&doc); + let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{a:#?}") }; + // An opacity mask: the swatch its art's fill is linked to. + let mask = |c: &Node| { + c.extras().mask.as_ref().map(|m| { + let mut swatch = None; + m.art.walk(&mut |p| { + if let Paint::Solid { swatch: Some(s), .. } = fill(p) { + swatch = Some(s); + } + }); + swatch + }) + }; + children.iter().map(|c| (c.kind.clone(), fill(c).color(), mask(c))).collect::>() + }; + let (red, blue) = (Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))); + // A child's kind, fill color and, with an opacity mask, the swatch its art is linked to. + type Child = (NodeKind, Option, Option>); + let paths = |c: &[Child]| { + assert!(matches!(c[0].0, NodeKind::Compound { .. }) && c.len() == 4, "{c:?}"); + c[..3].iter().map(|(k, c, m)| (matches!(k, NodeKind::Path { clipping: false, .. }), *c, m.clone())).collect::>() + }; + assert_eq!(paths(&children("", "")), [(false, None, None), (true, red, None), (true, red, None)]); + assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", "")), [(false, None, None), (true, red, None), (true, blue, None)]); + // The fill's copy of the mask stays linked to the mask art's global swatch. + let linked = Some(Some("Global white".to_string())); + assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", &mask)), [(false, None, None), (true, red, linked), (true, blue, None)]); +} + #[test] fn a_compound_path_of_a_plain_group_stays_one_compound_path() { // GUARDS: #980. Files write `9 () XW` after every group, the one a compound path keeps its @@ -3346,6 +3474,96 @@ fn a_tz_with_use_default_0_leaves_te_out() { assert_eq!(legacy_chars(setup, &["_F", "_S", "_M", "_G"], "\\047\\140\\200"), ["'`€", "'`€", "'`€", "’`Ä"]); } +/// Strings in a composite font `base-CMap` decode by that predefined CMap (codes to CIDs, then the +/// character collection's CIDs to Unicode), and the font is `base`; `TE`'s encoding does not apply +/// to them. Codes the CMap leaves out are dropped. A font whose name ends in no predefined CMap +/// decodes byte by byte. +#[test] +fn legacy_type_in_a_cjk_font_decodes_by_its_cmap() { + let typed = |font: &str, chars: &str| point_type("", chars).replace("/_Helvetica", &format!("/{font}")); + let cases: &[(&str, &str, &str)] = &[ + // 日 is 0x93FA and 本 0x967B in Shift-JIS; `\r` ends a paragraph. + ("83pv-RKSJ-H", "\\223\\372\\226{A\\r\\223\\372", "日本A\n日"), + // A tab stays a tab, also after a UTF-16 string's last whole code. + ("90ms-RKSJ-H", "\\223\\372\\tA", "日\tA"), + ("H", "F|\\tF|", "日\t日"), + ("UniJIS-UTF16-H", "e\\345\\t", "日\t"), + // One-byte codes in the Mac Shift-JIS CMap: 0x5C yen, 0x7E tilde, 0x80 backslash, 0xFD + // copyright, and the underscore and grave accent. + ("83pv-RKSJ-H", "\\\\~\\200\\375_`", "¥~\\©_`"), + ("90ms-RKSJ-H", "\\\\~_`", "¥~_`"), + ("90ms-RKSJ-V", "\\223\\372", "日"), + ("90pv-RKSJ-V", "\\223\\372", "日"), + // JIS row and cell: 日 is 0x4652. + ("H", "F|", "日"), + ("V", "F|", "日"), + ("EUC-H", "\\306\\374", "日"), + ("GBK-EUC-H", "\\326\\320", "中"), + ("GBpc-EUC-H", "\\326\\320", "中"), + ("B5pc-H", "\\244\\244", "中"), + ("KSCms-UHC-H", "\\307\\321", "한"), + ("KSCpc-EUC-H", "\\307\\321", "한"), + ("UniJIS-UCS2-H", "e\\345", "日"), + ("UniGB-UTF8-H", "\\344\\270\\255", "中"), + // An encoded U+FFFD is a character; the invalid byte 0xFF is dropped. + ("UniGB-UTF8-H", "\\357\\277\\275\\377\\344\\270\\255", "\u{fffd}中"), + ("UniJIS-UTF32-H", "\\000\\000e\\345", "日"), + ("UniAKR-UTF16-H", "\\254\\000", "가"), + // CMaps VectorCraft doesn't carry: read with the nearest carried one, row-and-cell codes in + // their EUC form (中 is 0x5650 in GB 2312, 한 0x4751 in KS X 1001), or byte by byte. + ("78-RKSJ-H", "\\223\\372", "日"), + ("RKSJ-V", "\\223\\372", "日"), + ("GB-H", "VP", "中"), + ("KSC-V", "GQ", "한"), + ("GBT-H", "AB", "AB"), + // Codes are as long as the code space range they fall in, and unmapped ones are dropped: + // 0x8540 as two bytes, and 0xFD and EUC's 0x8F (in no range) as one. + ("90ms-RKSJ-H", "\\205\\100\\223\\372A\\375B", "日AB"), + ("EUC-H", "\\217A\\306\\374", "A日"), + ]; + let art: String = cases.iter().map(|(cmap, chars, _)| typed(&format!("_Test-W3-{cmap}"), chars)).collect(); + let art = [art, typed("_Test-W3-Identity-H", "\\223"), typed("_Test-W3-UniFoo-UTF8-H", "\\223"), typed("_Helvetica", "\\223")].concat(); + let data = type_stream7(&art).replace("%%BeginSetup\n%%EndSetup", "%%BeginSetup\n[147/Adieresis\nTE\n%%EndSetup"); + let texts = art_texts(&read(data.as_bytes()).unwrap().doc); + let chars: Vec = texts.iter().map(|t| t.plain_text()).collect(); + let mut want: Vec<&str> = cases.iter().map(|(_, _, want)| *want).collect(); + want.extend(["Ä", "Ä", "Ä"]); + assert_eq!(chars, want); + let test = crate::import::family_style("Test-W3"); + for (t, (cmap, _, _)) in texts.iter().zip(cases) { + assert_eq!((&t.runs[0].style.font_family, &t.runs[0].style.font_style), (&test.0, &test.1), "{cmap}"); + } + assert_eq!(texts[0].paragraph_count(), 2); + assert_ne!(texts[cases.len()].runs[0].style.font_style, test.1, "an Identity CMap is not read"); + assert_ne!(texts[cases.len() + 1].runs[0].style.font_style, test.1, "`UniFoo` is not a character collection"); +} + +/// A font whose `TZ` gives encoding pairs is not a composite font, even when its name ends in a +/// CMap name: its strings decode byte by byte, and the font keeps its whole name. A `TZ` without +/// encoding pairs only names a composite font. +#[test] +fn a_re_encoded_font_ending_in_a_cmap_name_is_not_composite() { + let setup = "%AI3_BeginEncoding: _F-Bold-H F-Bold-H\n[ 39/quotesingle\n/_F-Bold-H/F-Bold-H 0 0 1 TZ\n%AI3_EndEncoding AdobeType\n"; + assert_eq!(legacy_chars(setup, &["_F-Bold-H"], "F|'"), ["F|'"]); + let setup = "%AI3_BeginEncoding: _G-Bold-H G-Bold-H\n[ 65/A\n/_G-Bold-H/G-Bold-H 0 0 1 TZ\n%AI3_EndEncoding AdobeType\n"; + assert_eq!(legacy_chars(setup, &["_G-Bold-H"], "F|"), ["F|"]); + let setup = "%AI3_BeginEncoding: _R-W3-83pv-RKSJ-H R-W3-83pv-RKSJ-H\n[/_R-W3-83pv-RKSJ-H/R-W3-83pv-RKSJ-H 0 1 1 TZ\n%AI3_EndEncoding AdobeType\n"; + assert_eq!(legacy_chars(setup, &["_R-W3-83pv-RKSJ-H"], "\\223\\372"), ["日"]); +} + +/// In a font with a Unicode CMap, a one-byte `(\r)` string, or a 0x0D byte after a string's last +/// whole UTF-16 code, still ends a paragraph, as in other fonts, and an overflow `(\r) TX` still +/// ends the story. +#[test] +fn legacy_type_in_a_utf16_font_keeps_its_paragraph_breaks() { + let art = point_type("", "e\\345") + .replace("/_Helvetica", "/_Test-W3-UniJIS-UTF16-H") + .replace("TO\n", "(\\r) Tx\n(e\\345\\r) Tx\n(e\\345) Tx\n(\\r) TX\nTO\n"); + let texts = legacy_texts(&art); + assert_eq!(texts[0].plain_text(), "日\n日\n日"); + assert_eq!(texts[0].paragraph_count(), 3); +} + fn art_texts(doc: &Document) -> Vec { art(doc).iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).collect() } @@ -3381,6 +3599,32 @@ fn legacy_type_keeps_its_spacing_hanging_punctuation_and_hyphenation() { assert_eq!(texts[1].para_at(0), two); } +/// `Tg` (§15.3.4) turns binding punctuation on (1 and 3) or off (0 and 2); VectorCraft opens it on +/// as Burasagari's Force setting. Each paragraph of point type and area type keeps the value in +/// force at its first character, and it lasts into the next text object. +#[test] +fn legacy_type_keeps_its_binding_punctuation() { + use vectorcraft_doc::Burasagari; + let burasagari = |attrs: &str| legacy_texts(&point_type(attrs, "Hi"))[0].para_at(0).burasagari; + assert_eq!(burasagari(""), Burasagari::None); + for on in ["1", "3"] { + assert_eq!(burasagari(&format!("0 Tg\n{on} Tg")), Burasagari::Forced, "{on} Tg"); + } + for off in ["0", "2"] { + assert_eq!(burasagari(&format!("1 Tg\n{off} Tg")), Burasagari::None, "{off} Tg"); + } + let two_paragraphs = point_type("1 Tg", "One\\r").replace("TO\n", "0 Tg\n(Two) Tx\nTO\n"); + let texts = legacy_texts(&format!("{two_paragraphs}{}{}", point_type("3 Tg", "Three"), point_type("", "Four"))); + let each: Vec = vec![texts[0].para_at(0).burasagari, texts[0].para_at(1).burasagari, texts[1].para_at(0).burasagari]; + assert_eq!(each, [Burasagari::Forced, Burasagari::None, Burasagari::Forced]); + assert_eq!(texts[2].para_at(0).burasagari, Burasagari::Forced, "the next text object keeps it"); + let frame = "10 10 m\n110 10 L\n110 110 L\n10 110 L\nN"; + let area = format!("1 To\n1 0 0 1 10 110 0 Tp\n{frame}\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n1 Tg\n(Area) Tx\nTO\n"); + let texts = legacy_texts(&area); + assert!(matches!(texts[0].kind, vectorcraft_doc::TextKind::Area { .. })); + assert_eq!(texts[0].para_at(0).burasagari, Burasagari::Forced, "area type"); +} + /// `TV` (§15.3.4) sets the writing direction of point type, area type, type on a path and linked /// areas, and lasts into the next text object until it changes. #[test] @@ -3426,6 +3670,31 @@ fn legacy_type_keeps_its_writing_direction() { let after = point_type("", "Hi").replace("TO\n", "1 TV\nTO\n"); let after = legacy_texts(&format!("{after}{}", point_type("", "Next"))); assert_eq!((after[0].vertical, after[1].vertical), (false, true)); + // An overflow break (`(\r) TX`) keeps the direction in force where it was read: at the start of + // the story it sets the object's direction, and later it warns when that differs. + let leading = legacy_read(&point_type("1 TV\n(\\r) TX\n0 TV", "Body")); + let leading = (art_texts(&leading.doc), leading.warnings); + assert_eq!((leading.0[0].vertical, leading.0[0].plain_text().as_str()), (true, "\nBody")); + assert_eq!(leading.1, [DIRECTION_CHANGE_LEFT_OUT]); + let inside = legacy_read(&point_type("", "a").replace("TO\n", "1 TV\n(\\r) TX\n0 TV\n(b) Tx\nTO\n")); + let inside = (art_texts(&inside.doc), inside.warnings); + assert_eq!((inside.0[0].vertical, inside.0[0].plain_text().as_str()), (false, "a\nb")); + assert_eq!(inside.1, [DIRECTION_CHANGE_LEFT_OUT]); +} + +/// An overflow break (`(\r) TX`) that type follows ends a paragraph as `(\r) Tx` does: an empty +/// paragraph it ends keeps the attributes in force where the break was read. +#[test] +fn an_overflow_break_keeps_the_paragraph_attributes_it_was_read_with() { + use vectorcraft_doc::Justify::{Center, Right}; + for op in ["Tx", "TX"] { + let leading = &legacy_texts(&point_type(&format!("1 Ta\n(\\r) {op}\n2 Ta"), "Body"))[0]; + assert_eq!((leading.para_at(0).justify, leading.para_at(1).justify), (Center, Right), "{op}"); + let empty = point_type("1 Ta", "a\\r").replace("TO\n", &format!("(\\r) {op}\n2 Ta\n(b) Tx\nTO\n")); + let empty = &legacy_texts(&empty)[0]; + assert_eq!(empty.plain_text(), "a\n\nb", "{op}"); + assert_eq!((empty.para_at(1).justify, empty.para_at(2).justify), (Center, Right), "{op}"); + } } /// The glyph origins of a legacy-format text object. @@ -3496,6 +3765,10 @@ fn legacy_type_attributes_with_bad_operands_stay_as_they_were() { assert_eq!(p.justify, vectorcraft_doc::Justify::Right); assert_eq!((p.first_line_indent, p.left_indent, p.right_indent), (5.0, 6.0, 7.0)); assert_eq!(texts[0].runs[0].style.kerning, Some(0.0), "kerning stays off"); + let texts = legacy_texts(&point_type("1 Tg\n4 Tg\n-1 Tg\n0.5 Tg\n/x Tg\n(0) Tg\nTg", "Hi")); + assert_eq!(texts[0].para_at(0).burasagari, vectorcraft_doc::Burasagari::Forced, "binding punctuation stays on"); + let texts = legacy_texts(&point_type("0 Tg\n4 Tg\n-1 Tg\n2.5 Tg", "Hi")); + assert_eq!(texts[0].para_at(0).burasagari, vectorcraft_doc::Burasagari::None, "binding punctuation stays off"); let texts = legacy_texts(&point_type("1 TV\n1 Tv\n1 Ty\n2 TV\n/x TV\nTV\n-1 Tv\n(x) Tv\n0.5 Ty\n2 TY", "Hi")); let st = &texts[0].runs[0].style; assert!(texts[0].vertical && st.standard_vertical_roman && st.tate_chu_yoko); @@ -3700,7 +3973,7 @@ fn legacy_type_in_a_mask_rendering_mode_clips_its_clipping_group() { assert!(matches!(inner[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(inner[1].kind, NodeKind::Path { clipping: false, .. })); } -/// A filled clipping path (`W` then `f`) beside type used as a mask: its painted fill stays at its own path's level, +/// A filled clipping path (`W` then `f`) beside type used as a mask keeps its fill, which paints at its own path's level, /// as beside another clipping path (the reader's rule for `W` paths). Written after the type, it is painted within the /// type's mask, since a later mask narrows the clip set by the earlier ones (§5.6.2); written before it, the type doesn't /// clip it. @@ -3709,23 +3982,21 @@ fn a_filled_clipping_path_beside_legacy_type_used_as_a_mask() { let square = format!("1 0 0 Xa\n{}f\n", rect_path(0.0, 0.0, 100.0, 100.0)); let filled = format!("0 0 1 Xa\n{}h\nW\nf\n", rect_path(0.0, 0.0, 30.0, 30.0)); let text = point_type("7 Tr", "Hi"); - let is_fill = |n: &Node| matches!(n.kind, NodeKind::Path { clipping: false, .. }) && fill(n).color() == Some(Color::rgb(0.0, 0.0, 1.0)); + let is_clip = |n: &Node| matches!(n.kind, NodeKind::Path { clipping: true, .. }) && fill(n).color() == Some(Color::rgb(0.0, 0.0, 1.0)); let is_art = |n: &Node| matches!(n.kind, NodeKind::Path { clipping: false, .. }) && fill(n).color() == Some(Color::rgb(1.0, 0.0, 0.0)); - // The type first: the path's fill is in the clipping group the path clips, inside the type's. + // The type first: the filled path clips a clipping group inside the type's. let doc = legacy_read(&format!("q\n{text}{filled}{square}Q\n")).doc; let a = art(&doc); let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{:?}", a[0].kind) }; assert!(matches!(children[0].kind, NodeKind::Text(_)) && children.len() == 2, "{children:?}"); let NodeKind::Group { children: inner, clip: true } = &children[1].kind else { panic!("{:?}", children[1].kind) }; - assert!(matches!(inner[0].kind, NodeKind::Path { clipping: true, .. }), "{inner:?}"); - assert!(is_fill(&inner[1]) && is_art(&inner[2]) && inner.len() == 3, "{inner:?}"); - // The path first: its fill is in the outer clipping group, which only the path clips; the type clips the art. + assert!(is_clip(&inner[0]) && is_art(&inner[1]) && inner.len() == 2, "{inner:?}"); + // The path first: it clips the outer clipping group, so only the path clips its fill; the type clips the art. let doc = legacy_read(&format!("q\n{filled}{text}{square}Q\n")).doc; let a = art(&doc); let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{:?}", a[0].kind) }; - assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }), "{children:?}"); - assert!(is_fill(&children[1]) && children.len() == 3, "{children:?}"); - let NodeKind::Group { children: inner, clip: true } = &children[2].kind else { panic!("{:?}", children[2].kind) }; + assert!(is_clip(&children[0]) && children.len() == 2, "{children:?}"); + let NodeKind::Group { children: inner, clip: true } = &children[1].kind else { panic!("{:?}", children[1].kind) }; assert!(matches!(inner[0].kind, NodeKind::Text(_)) && is_art(&inner[1]) && inner.len() == 2, "{inner:?}"); } @@ -5161,10 +5432,15 @@ fn editing_data_past_the_limit_is_refused() { fn many_zstandard_frames_are_read_in_one_pass_and_capped() { let frame = zstd(b" "); let frames = |n: usize| -> Vec { [&b"%AI24_ZStandard_Data"[..], &frame.repeat(n), &[0; 64]].concat() }; - let started = std::time::Instant::now(); - assert_eq!(decode_private(&frames(4000)).unwrap().len(), 4000); - assert!(decode_private(&frames(50_000)).unwrap_err().contains("Zstandard")); - assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed()); + let (few, many) = (frames(4000), frames(50_000)); + let before = padding_looks(); + assert_eq!(decode_private(&few).unwrap().len(), 4000); + assert!(decode_private(&many).unwrap_err().contains("Zstandard")); + // The first byte of each of the 4,000 frames and of the 4,097 read before the cap, and the + // 64 zeros of padding once: looking past the frames to the padding before each frame would + // look at 64 or more bytes per frame. + let looked = padding_looks() - before; + assert!(looked <= 2 * (4000 + 4097 + 64), "looked at {looked} bytes for padding"); } /// A Zstandard frame that ends in a zero byte keeps it: only the zeros after the last frame are @@ -5206,8 +5482,9 @@ fn artboards_after_what_stops_the_reading() { #[test] fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() { - // GUARDS: the painted copies of a compound's clipping subpaths went into the compound itself, - // doubling each subpath, so under even-odd the clip cancelled to nothing. + // GUARDS: copies that painted a compound's clipping subpaths went into the compound itself, + // doubling each subpath, so under even-odd the clip cancelled to nothing. The compound + // clipping path keeps the fill itself. let art_ = format!( "0 Ae\nq\n{RED}{}f\n1 XR\n*u\n0 0 0 0 0 0 1 Xa\n{}W\nf\n{}W\nf\n*U\nQ\n", rect_path(0.0, 0.0, 100.0, 100.0), @@ -5216,14 +5493,13 @@ fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() { ); let doc = read_ok(&layer(&art_)).doc; let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") }; - assert_eq!(children.len(), 3, "{children:#?}"); + assert_eq!(children.len(), 2, "{children:#?}"); let NodeKind::Compound { children: clip, rule } = &children[0].kind else { panic!("{:?}", children[0].kind) }; assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd); assert_eq!(clip.len(), 2); assert!(clip.iter().all(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. }))); - let NodeKind::Compound { children: painted, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) }; - assert_eq!((painted.len(), fill(&children[1]).color()), (2, Some(Color::rgb(0.0, 0.0, 1.0)))); - assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); + assert_eq!(fill(&children[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(fill(&children[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0))); } #[test] @@ -6601,6 +6877,134 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { assert_eq!(r.warnings, [super::envelope::DRAWN]); } +/// A document of `n` warped text objects, with the art the live warp makes of each, in its look. +fn warped_type_doc(n: usize) -> (Document, Vec) { + use vectorcraft_doc::{Appearance, Effect, TextObject}; + let mut doc = Document::new(400.0, 200.0); + let mut looks = vec![]; + let mut layer = Node::layer(doc.alloc_id(), "Art", vectorcraft_doc::LayerColor::Preset(0)); + for i in 0..n { + let t = TextObject::point(kurbo::Point::new(20.0, 40.0 + 40.0 * i as f64), "TILT", Default::default()); + let mut text = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + let mut appearance = Appearance::default(); + appearance.effects.push(Effect { id: "warp.arc".into(), params: serde_json::json!({"bend": 40.0}), visible: true }); + text.appearance = appearance.into(); + text.name = Some(format!("Type {i}")); + text.locked = i == 0; + text.visible = i != 0; + let mut look = vectorcraft_effects::expand_outlined(None, &text, None).unwrap(); + look.id = doc.alloc_id(); + looks.push(super::envelope::WarpedLook { slot: text.id, look, left_out: None }); + layer.children_mut().unwrap().push(Arc::new(text)); + } + doc.layers = vec![Arc::new(layer)]; + (doc, looks) +} + +/// Warped text objects share the import's budget of checks. One past it keeps its drawn art with its +/// text object's name and state, as one that fails the check does, and its box and wrap setting. +#[test] +fn warped_type_past_the_budget_comes_in_as_its_look() { + let (mut doc, mut looks) = warped_type_doc(2); + let (first, second) = (looks[0].slot, looks[1].slot); + let left_out = Rect::new(1.0, 2.0, 3.0, 4.0); + looks[1].left_out = Some(left_out); + let wrap = vectorcraft_doc::TextWrap::default(); + doc.node_mut(second).unwrap().extras_mut().wrap = Some(wrap); + let warped = super::envelope::WarpedType { looks, checks: 1, ..Default::default() }; + let mut restyled = vec![Rect::new(5.0, 6.0, 7.0, 8.0), left_out]; + assert_eq!(warped.settle(&mut doc, Some, &Default::default(), &mut restyled), 1); + assert_eq!(restyled, [Rect::new(5.0, 6.0, 7.0, 8.0)]); + assert!(matches!(doc.node(first).unwrap().kind, NodeKind::Text(_))); + let kept = doc.node(second).unwrap(); + assert!(matches!(kept.kind, NodeKind::Group { .. } | NodeKind::Path { .. }), "{kept:#?}"); + assert_eq!((kept.name.as_deref(), kept.visible, kept.locked), (Some("Type 1"), true, false)); + assert_eq!(kept.extras().wrap, Some(wrap)); +} + +/// The budget for warped type is what the envelopes left: their checks and the page's share they +/// spent, with the page they are held to. +#[test] +fn warped_type_gets_what_the_envelopes_left_of_the_budget() { + let flat = vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }; + let exact = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&flat)))); + let beside = format!("{RED}{}f\n", rect_path(40.5, 20.0, 160.5, 80.0)); + let shifted = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &beside))); + for r in [&exact, &shifted] { + assert_eq!(r.warped_type.checks, super::envelope::MAX_CHECKED - 1); + assert!(r.warped_type.page.is_some()); + } + live_envelope(&art(&shifted.doc)[0]); + assert!(shifted.warped_type.spent.any > exact.warped_type.spent.any); +} + +/// In a file with an artboard list, warped type is held to the first artboard, not to the crop marks. +#[test] +fn warped_type_is_held_to_the_first_artboard() { + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let r = read_ok(&format!("{boards}{}", layer(&format!("{RED}{}f\n", rect_path(20.0, 20.0, 40.0, 40.0))))); + assert_eq!(r.doc.artboards[0].rect, Rect::new(0.0, 0.0, 300.0, 200.0)); + assert_eq!(r.warped_type.page, Some(Rect::new(0.0, 0.0, 300.0, 200.0))); +} + +/// Type whose Warp is hidden draws no warp, so it stays type even with no check left. +#[test] +fn type_with_a_hidden_warp_stays_type() { + let (mut doc, looks) = warped_type_doc(1); + let slot = looks[0].slot; + let mut appearance = (*doc.node(slot).unwrap().appearance).clone(); + appearance.effects[0].visible = false; + doc.node_mut(slot).unwrap().appearance = appearance.into(); + let warped = super::envelope::WarpedType { looks, checks: 0, ..Default::default() }; + assert_eq!(warped.settle(&mut doc, Some, &Default::default(), &mut vec![]), 0); + assert!(matches!(doc.node(slot).unwrap().kind, NodeKind::Text(_))); +} + +/// A warped frame of threaded type stays type, whatever its look draws: the thread's frames share +/// one story. So does a slot that became no text object. +#[test] +fn warped_type_in_a_thread_stays_type() { + let (mut doc, looks) = warped_type_doc(2); + let (first, second) = (looks[0].slot, looks[1].slot); + let warped = super::envelope::WarpedType { looks, ..Default::default() }; + assert_eq!(warped.settle(&mut doc, |id| (id != second).then_some(id), &[first].into(), &mut vec![]), 0); + assert!(matches!(doc.node(first).unwrap().kind, NodeKind::Text(_))); + assert!(matches!(doc.node(second).unwrap().kind, NodeKind::Text(_))); +} + +/// What is left of the page's share of the budget decides, and the page's share counts only for +/// what the document shows. +#[test] +fn warped_type_on_the_page_spends_what_is_left_of_the_budget() { + use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE}; + let (doc, mut looks) = warped_type_doc(2); + // The second object shows; the first is hidden. Each look draws a little more than its object. + let mark = read_ok(&layer(&format!("{RED}{}f\n", rect_path(2.0, 2.0, 2.6, 2.6)))); + let mark = art(&mark.doc)[0].clone(); + for (i, w) in looks.iter_mut().enumerate() { + w.look.visible = true; + w.look = Node::group(vectorcraft_doc::NodeId(u64::MAX - 10 - i as u64), vec![Arc::new(w.look.clone()), mark.clone()]); + } + let copy = |w: &super::envelope::WarpedLook| super::envelope::WarpedLook { slot: w.slot, look: w.look.clone(), left_out: None }; + let page = Some(Rect::new(0.0, 60.0, 60.0, 100.0)); + let settle = |w: &super::envelope::WarpedLook, spent| { + let warped = super::envelope::WarpedType { looks: vec![copy(w)], checks: 1, spent, page }; + warped.settle(&mut doc.clone(), Some, &Default::default(), &mut vec![]) + }; + let spent = || super::envelope::Spent { any: MAX_DIFFERENCE / 2.0, solid: MAX_SOLID_DIFFERENCE / 2.0 }; + assert_eq!(settle(&looks[1], Default::default()), 0); + assert_eq!(settle(&looks[1], spent()), 1); + assert_eq!(settle(&looks[0], spent()), 0); +} + /// An envelope that comes out close to its drawn art, but with more of the page differing than the /// page check can take from the live envelopes (its drawn art 2 pt to the side on a 200 × 100 /// page), keeps the drawn art, so the file still opens from its layers. @@ -6994,19 +7398,25 @@ pub(super) fn blend_file(spine: &str, ids: [Option<&str>; 2], record: &str) -> S /// [`blend_file`] with the left key in RGB `from`, the right one in `to`, and the steps drawn /// between their colors. fn blend_file_colored(spine: &str, ids: [Option<&str>; 2], record: &str, from: [f64; 3], to: [f64; 3]) -> String { + blend_file_apart(spine, ids, record, from, to, 100.0, 3) +} + +/// [`blend_file_colored`] with the right key `apart` points right of the left one and `between` +/// steps drawn between them. +fn blend_file_apart(spine: &str, ids: [Option<&str>; 2], record: &str, from: [f64; 3], to: [f64; 3], apart: f64, between: usize) -> String { let mut art = String::from("%_0 Ae\n%_u\n"); art += &square(0.0, 40.0, 20.0, from, true); art += &blend_part("K", ids[0]); - art += &square(100.0, 40.0, 20.0, to, true); + art += &square(apart, 40.0, 20.0, to, true); art += &blend_part("K", ids[1]); art += spine; art += "%_N\n"; art += &blend_part("S", None); art += "%_U\n%_9 () XW\nu\n"; - for i in 0..5 { - let t = i as f64 / 4.0; + for i in 0..between + 2 { + let t = i as f64 / (between + 1) as f64; let rgb = [0, 1, 2].map(|c| from[c] + (to[c] - from[c]) * t); - art += &square(t * 100.0, 40.0, 20.0, rgb, false); + art += &square(t * apart, 40.0, 20.0, rgb, false); } art += "U\n9 () XW\n0 Ae\n"; art += record; @@ -7082,7 +7492,7 @@ fn a_blends_spine_written_backwards_keeps_its_keys_order() { /// A blend's spacing comes from its record: Specified Distance with its distance (25 pt from one /// step's edge to the corresponding edge of the next lays three out between keys 100 pt apart), -/// Smooth Color (keys 5/255 apart in blue take three steps), and Specified Steps with the steps +/// Smooth Color (keys 3/255 apart in blue take three steps), and Specified Steps with the steps /// counted from the drawn ones, whatever its record's word 4 holds. The distance counts along the /// spine between the keys, also where the spine runs on past the last key. #[test] @@ -7090,7 +7500,7 @@ fn a_blend_keeps_its_spacing() { use vectorcraft_doc::live::BlendSpacing; let straight = "%_10 50 m\n%_110 50 L\n"; let longer = "%_10 50 m\n%_110 50 L\n%_1010 50 L\n"; - let (red, nearly_red, blue) = ([1.0, 0.0, 0.0], [1.0, 0.0, 5.0 / 255.0], [0.0, 0.0, 1.0]); + let (red, nearly_red, blue) = ([1.0, 0.0, 0.0], [1.0, 0.0, 3.0 / 255.0], [0.0, 0.0, 1.0]); for (spine, record, (from, to), spacing) in [ (straight, blend_record_spaced(&['A', 'B'], 2, 25.0, 3), (red, blue), BlendSpacing::Distance(25.0)), (longer, blend_record_spaced(&['A', 'B'], 2, 25.0, 3), (red, blue), BlendSpacing::Distance(25.0)), @@ -7106,6 +7516,23 @@ fn a_blend_keeps_its_spacing() { } } +/// Smooth Color blends of two 20 pt squares 200 pt apart come in live with the steps drawn +/// between them: 254 from red to blue, and 9 between two red squares. +#[test] +fn a_smooth_color_blend_comes_in_with_its_drawn_steps() { + use vectorcraft_doc::live::BlendSpacing; + let (red, blue) = ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]); + for (to, between) in [(blue, 254), (red, 9)] { + let record = blend_record_spaced(&['A', 'B'], 0, 4.0, between as u32); + let body = blend_file_apart("%_10 50 m\n%_210 50 L\n", [Some("A"), Some("B")], &record, red, to, 200.0, between); + let (blend, warnings) = read_blend(&body); + let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a live blend for {between} steps: {:?} {warnings:?}", blend.kind) }; + assert_eq!(spec.spacing, BlendSpacing::SmoothColor); + assert_eq!(vectorcraft_doc::blend::blend_expand(children, spec).len(), between + 2); + assert!(!warnings.iter().any(|w| w == super::blend::DRAWN), "{between}: {warnings:?}"); + } +} + /// A blend whose spacing doesn't lay out its drawn steps keeps them: a distance that lays out /// nine, and Smooth Color between red and blue (more steps than the three drawn); and so does a /// spacing the reader doesn't know or a distance that isn't a positive number. @@ -7797,6 +8224,118 @@ fn a_brushs_art_takes_the_patterns_defined_before_it() { assert!(matches!(fill(&s.art), Paint::Pattern { ref pattern, .. } if pattern == "Dots"), "{:?}", r.warnings); } +/// A spot color that only a brush's art uses comes in as a swatch, and the art's process colors +/// count toward the document's color mode, as a pattern's do. +#[test] +fn a_brushs_art_brings_its_spot_colors_and_color_mode() { + let section = format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n0 1 1 0 (Ink) 0 x\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 4.0, 4.0)); + let r = read_ok(&format!("{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + assert!(matches!(brush_named(&lib, "Spray"), vectorcraft_brush::BrushKind::Scatter(_)), "{:?}", r.warnings); + let ink = r.doc.swatch("Ink").map(|s| (s.paint.color(), s.global, s.spot)); + assert_eq!(ink, Some((Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)), true, true)), "{:?}", r.warnings); + assert_eq!(r.doc.color_mode, ColorMode::Cmyk); + // Art that isn't kept brings nothing. + let odd = "%AI8_BeginBrushPattern\n(Odd art)\n0 1 1 0 (Lost ink) 0 x\n0 0 m\n1 1 L\nnotanoperator\nf\n%AI8_EndBrushPattern\n"; + let r = read_ok(&format!("{odd}{}{}", plugin_object(SCATTER, "Odd", &SPRAY.replace("Dot art", "Odd art")), layer(""))); + assert!(r.doc.swatch("Lost ink").is_none()); + assert_eq!(r.doc.color_mode, ColorMode::Rgb); + // Nor does art that only a brush left out uses (a pattern brush). + let tile = "%AI8_BeginBrushPattern\n(Tile one)\n0 A\n0 1 1 0 (Fence ink) 0 x\n0 0 m\n4 0 L\n4 4 L\nf\n%AI8_EndBrushPattern\n"; + let r = read_ok(&format!("{tile}{}{}", plugin_object(PATTERN_BRUSH, "Fence", FENCE), layer(""))); + assert!(vectorcraft_brush::library(&r.doc).is_empty(), "{:?}", r.warnings); + assert!(r.doc.swatch("Fence ink").is_none()); + assert_eq!(r.doc.color_mode, ColorMode::Rgb); +} + +/// A spot color that the page's art and a brush's art both define keeps the page's value, and a +/// brush art section that a later one of the same name replaces brings none of its colors. +#[test] +fn a_brushs_art_colors_come_after_the_pages_and_a_replaced_sections_bring_nothing() { + let section = |ink: &str, color: &str| { + format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n{color} ({ink}) 0 x\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 4.0, 4.0)) + }; + let page = layer(&format!("0 0 1 0 (Ink) 0 x\n{}f", rect_path(0.0, 0.0, 10.0, 10.0))); + let body = format!("{}{}{}{page}", section("Gone ink", "1 0 0 0"), section("Ink", "0 1 1 0"), plugin_object(SCATTER, "Spray", SPRAY)); + let r = read_ok(&body); + assert!(matches!(brush_named(&vectorcraft_brush::library(&r.doc), "Spray"), vectorcraft_brush::BrushKind::Scatter(_))); + assert_eq!(r.doc.swatch("Ink").and_then(|s| s.paint.color()), Some(Color::cmyk(0.0, 0.0, 1.0, 0.0)), "{:?}", r.warnings); + assert!(r.doc.swatch("Gone ink").is_none()); +} + +/// The art of a file's brushes brings each color name's first definition once, and at most +/// [`super::brush::MAX_BRUSH_ART_COLORS`] names; the colors it leaves out still count toward the +/// color mode, and art that no brush uses, or that a later section replaces, takes no places. +#[test] +fn brush_art_keeps_each_color_name_once_and_a_bounded_number_of_names() { + let open = |sections: &[(&str, &str)]| { + let defs: String = sections + .iter() + .map(|(name, colors)| { + format!("%AI8_BeginBrushPattern\n({name})\n0 A\n{colors}{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 4.0, 4.0)) + }) + .collect(); + let mist = plugin_object(SCATTER, "Mist", &SPRAY.replace("Dot art", "Dash art")); + read_ok(&format!("{defs}{}{mist}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(""))).doc + }; + let max = super::brush::MAX_BRUSH_ART_COLORS; + let ink = "0 1 1 0 (Ink) 0 x\n"; + let many: String = (0..max).map(|i| format!("1 0 0 0 (Name {i}) 0 x\n")).collect(); + // One name repeated takes one place. + let doc = open(&[("Dot art", &format!("{}{ink}", "1 0 0 0 (Same) 0 x\n".repeat(max)))]); + assert!(doc.swatch("Same").is_some() && doc.swatch("Ink").is_some()); + // Different names fill the places: the name past them is left out, and its color still counts. + let doc = open(&[("Dot art", &format!("{many}{ink}"))]); + assert!(doc.swatch("Name 0").is_some() && doc.swatch("Ink").is_none()); + assert_eq!(doc.color_mode, ColorMode::Cmyk); + // The places are shared by the art of every brush, and a name in the art of two takes one. + let doc = open(&[("Dot art", &many), ("Dash art", ink)]); + assert_eq!(vectorcraft_brush::library(&doc).len(), 2); + assert!(doc.swatch("Name 0").is_some() && doc.swatch("Ink").is_none()); + let half: String = many.lines().take(max / 2).map(|l| format!("{l}\n")).collect(); + let doc = open(&[("Dot art", &half), ("Dash art", &format!("{half}{ink}"))]); + assert!(doc.swatch("Name 0").is_some() && doc.swatch("Ink").is_some()); + // The color operators of every brush's art take places before the gradient stops of any. + let stops: String = (0..max / 250) + .map(|g| { + let named: String = (0..250).map(|s| format!("%_0 0 1 0 (Stop {g} {s}) 0 0 5 1 6 50 {} Bs\n", s * 100 / 250)).collect(); + format!("%AI5_BeginGradient: (G{g})\n(G{g}) 0 250 Bd\n[\n<00FF>\n0\n{named}BD\n%AI5_EndGradient\n") + }) + .collect(); + let doc = open(&[("Dot art", &stops), ("Dash art", ink)]); + assert!(doc.swatch("Stop 0 0").is_some() && doc.swatch("Ink").is_some(), "{:?}", doc.swatch("Stop 0 0")); + // Art that no brush uses, and art that a later section of its name replaces, take no places. + let doc = open(&[("Other art", &many), ("Dot art", &many), ("Dot art", ink)]); + assert!(doc.swatch("Ink").is_some() && doc.swatch("Name 0").is_none()); +} + +/// A global process color that only a brush's art defines with its color operator stays a process +/// color when a gradient of the file names it as a stop, as the page's own definition does (#1771). +#[test] +fn a_brushs_art_defines_a_process_color_that_a_gradient_stop_names() { + let defs = "%AI5_BeginGradient: (Brand)\n(Brand) 0 2 Bd\n[\n<00FF>\n0\n\ + %_0.93 0.1 0 0 (Test Cyan) 0 0 5 1 6 50 0 Bs\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let section = format!( + "%AI8_BeginBrushPattern\n(Dot art)\n0 A\n0.93 0.1 0 0 (Test Cyan) 0 0 Xk\n{}f\n%AI8_EndBrushPattern\n", + rect_path(0.0, 0.0, 4.0, 4.0) + ); + let doc = read_ok(&format!("{defs}{section}{}{}", plugin_object(SCATTER, "Spray", SPRAY), layer(""))).doc; + assert!(matches!(brush_named(&vectorcraft_brush::library(&doc), "Spray"), vectorcraft_brush::BrushKind::Scatter(_))); + let sw = doc.swatch("Test Cyan").unwrap(); + assert!(sw.global && !sw.spot, "a global process color, not an ink: {sw:?}"); +} + +/// A brush art section inside a symbol's definition isn't kept, so its spot colors make no swatch. +#[test] +fn brush_art_inside_a_symbol_brings_no_colors() { + let section = + format!("%AI8_BeginBrushPattern\n(Dot art)\n0 A\n0 1 1 0 (Hidden ink) 0 x\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 4.0, 4.0)); + let body = format!("{}{}{}", symbol("S", &format!("{section}{}", dot()), 5), plugin_object(SCATTER, "Spray", SPRAY), layer("")); + let r = read_ok(&body); + assert!(r.doc.symbols.iter().any(|s| s.name == "S"), "{:?}", r.warnings); + assert!(r.doc.swatch("Hidden ink").is_none()); +} + /// A brush's art filled with a pattern defined in the version 7 form places the tiles with the /// fill's offset, scale, rotation, reflection and shear operands, and a brush whose art has such a /// fill without all nine of them isn't read. @@ -7832,6 +8371,172 @@ fn a_brushs_art_places_a_version_7_patterns_tiles_with_its_operands() { assert!(!vectorcraft_brush::library(&r.doc).iter().any(|b| b.name == "Spray"), "{:?}", r.warnings); } +/// A Scatter brush whose 15 numbers start with 1 colors its art with Tints of the stroke, and one +/// whose 15 numbers start with 0 keeps the art's colors. The field before the art's name doesn't +/// count: a brush whose first field is 1 is read by its 15 numbers, and one with 8 numbers, as older +/// files write their brushes, isn't read as a Scatter brush. +#[test] +fn a_scatter_brush_in_tints_comes_in_tinted() { + use vectorcraft_brush::{BrushKind, Colorization}; + let read = |params: &str| { + let r = read_ok(&format!("{}{}{}", brush_art("Dot art", 4.0), plugin_object(SCATTER, "Spray", params), layer(""))); + vectorcraft_brush::library(&r.doc).into_iter().find(|b| b.name == "Spray") + }; + for (field, first, colorization) in + [("2", "1", Colorization::Tints), ("2", "0", Colorization::None), ("1", "1", Colorization::Tints), ("1", "0", Colorization::None)] + { + let params = format!("{field} /Dot art/ 5 0 0 0 / {first} 1 1 0 1 1 0 0 0 0 0 0 0 0 0"); + let Some(b) = read(¶ms) else { panic!("{params}: not read") }; + let BrushKind::Scatter(s) = b.kind else { panic!("scatter") }; + assert_eq!(s.colorization, colorization, "{params}"); + } + let older = "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"; + assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", older), Err(brush::BrushError::Values)); + assert!(read(older).is_none(), "8 numbers aren't a Scatter brush's"); +} + +/// A brush's art section `name`: in a group, a frame (a rectangle from `frame.0` to `frame.1` on +/// both axes, painted by `op` and followed by `after`) and a filled square from 0 to 4. +fn framed_art(name: &str, frame: (f64, f64), op: &str, after: &str) -> String { + format!( + "%AI8_BeginBrushPattern\n({name})\n0 A\n0 Xw\nu\n0 XR\n{}{op}\n{after}0 g\n{}f\nU\n%AI8_EndBrushPattern\n", + rect_path(frame.0, frame.0, frame.1, frame.1), + rect_path(0.0, 0.0, 4.0, 4.0) + ) +} + +/// The frame of a brush's art, an unnamed, unpainted rectangle at the art's bounds, isn't part of +/// the art, closed or open with its first corner again at the end. A frame wider than the art is +/// part of it, and so are a painted rectangle and a named one, which aren't frames. +#[test] +fn a_brushs_frame_is_left_out_only_at_the_arts_bounds() { + use vectorcraft_brush::BrushKind; + let named = "%_/ArtDictionary :\n%_/XMLUID : (Edge) ; (AI10_ArtUID) ,\n%_;\n%_\n"; + for (why, frame, op, after, kept) in [ + ("exact", (0.0, 4.0), "n", "", false), + ("open", (0.0, 4.0), "N", "", false), + ("wider", (-1.0, 5.0), "n", "", true), + ("filled", (0.0, 4.0), "f", "", true), + ("named", (0.0, 4.0), "n", named, true), + ] { + let params = "2 /Dot art/ 5 0 0 0 / 0 1 1 0 1 1 0 0 0 0 0 0 0 0 0"; + let r = read_ok(&format!("{}{}{}", framed_art("Dot art", frame, op, after), plugin_object(SCATTER, "Spray", params), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + let BrushKind::Scatter(s) = brush_named(&lib, "Spray") else { panic!("scatter") }; + if kept { + assert!(matches!(s.art.kind, NodeKind::Group { .. }), "{why}: {:#?}", s.art); + assert_eq!(s.art.children().map(|c| c.len()), Some(2), "{why}"); + } else { + assert_eq!(s.art.geometric_bounds().map(|b| b.width()), Some(4.0), "{why}: {:#?}", s.art); + assert!(matches!(s.art.kind, NodeKind::Path { .. }), "{why}: the square alone: {:#?}", s.art); + } + } +} + +/// A brush's art group that is hidden, locked, isolated or has a note keeps that state when its +/// frame is left out: the group stays, without the frame, rather than becoming its one other object. +#[test] +fn a_brushs_frame_leaves_the_groups_own_state() { + use vectorcraft_doc::{Appearance, FillLayer, NodeId}; + let square = Rect::new(0.0, 0.0, 4.0, 4.0); + let paint = FillLayer::new(Paint::solid(Color::BLACK)); + let ap = Appearance { items: vec![AppearanceItem::Fill(paint)], effects: vec![], contents_index: None }; + let dot = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(square), ap); + let frame = Node::path(NodeId(1), vectorcraft_geom::shapes::rectangle(square), Default::default()); + let group = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(dot)]); + assert!(matches!(super::brush::without_frame(group.clone()).kind, NodeKind::Path { .. }), "a plain group is its one object"); + // A closed path through the four corners across the square, not along its sides, isn't a frame. + let corners = [Point::new(0.0, 0.0), Point::new(4.0, 4.0), Point::new(4.0, 0.0), Point::new(0.0, 4.0)]; + let crossing = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&corners, true)); + let bowtie = Node::path(NodeId(4), crossing, Default::default()); + let dot = group.children().and_then(|c| c.last().cloned()).unwrap(); + let crossed = Node::group(NodeId(5), vec![Arc::new(bowtie), dot]); + assert_eq!(super::brush::without_frame(crossed).children().map(|c| c.len()), Some(2), "a path across the corners stays"); + // An open path along three sides isn't a frame; one that comes back to its first corner is. + let sides = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0), Point::new(0.0, 4.0)]; + let around = [sides.as_slice(), &sides[..1]].concat(); + for (why, anchors, frame) in [("three sides", sides.as_slice(), false), ("back to the first corner", around.as_slice(), true)] { + let open = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(anchors, false)); + let dot = group.children().and_then(|c| c.last().cloned()).unwrap(); + let g = Node::group(NodeId(6), vec![Arc::new(Node::path(NodeId(7), open, Default::default())), dot]); + let left = if frame { None } else { Some(2) }; + assert_eq!(super::brush::without_frame(g).children().map(|c| c.len()), left, "{why}"); + } + // A rectangle with settings of its own (a note, Show Center, hidden) isn't a frame. + for why in ["note", "show center", "hidden"] { + let mut g = group.clone(); + if let Some(Some(first)) = g.children_mut().map(|c| c.first_mut()) { + let first = Arc::make_mut(first); + match why { + "note" => first.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { note: "edge".into(), ..Default::default() })), + "show center" => { + first.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { show_center: Some(true), ..Default::default() })) + } + _ => first.visible = false, + } + } + assert_eq!(super::brush::without_frame(g).children().map(|c| c.len()), Some(2), "{why}: the rectangle stays"); + } + for why in ["hidden", "locked", "isolated", "note"] { + let mut g = group.clone(); + (g.visible, g.locked, g.isolate) = (why != "hidden", why == "locked", why == "isolated"); + if why == "note" { + g.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { note: "edge".into(), ..Default::default() })); + } + let out = super::brush::without_frame(g); + assert!(matches!(out.kind, NodeKind::Group { .. }), "{why}: {out:#?}"); + assert_eq!(out.children().map(|c| c.len()), Some(1), "{why}: the frame left out"); + assert_eq!((out.visible, out.locked, out.isolate), (why != "hidden", why == "locked", why == "isolated"), "{why}"); + assert_eq!(out.extras().attrs.as_ref().map(|a| a.note.as_str()), (why == "note").then_some("edge"), "{why}"); + } +} + +/// Leaving out a frame that is exactly the art's bounds doesn't move what a Scatter or Art brush +/// draws: the pieces along a path are where they are with the frame kept. +#[test] +fn a_brushs_frame_at_the_arts_bounds_draws_the_same_without_it() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Scatter}; + let scatter = ("Spray", "Dot art", SCATTER, "2 /Dot art/ 5 0 0 0 / 0 1 1 0 1 1 0 0 0 0 0 0 0 0 0"); + for (name, art_name, tool, params) in [scatter, ("Ribbon", "Ribbon art", ART_BRUSH, RIBBON)] { + let r = read_ok(&format!("{}{}{}", framed_art(art_name, (0.0, 4.0), "n", ""), plugin_object(tool, name, params), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + let read = lib.iter().find(|b| b.name == name).cloned().unwrap(); + let art = match &read.kind { + BrushKind::Scatter(s) => s.art.clone(), + BrushKind::Art(a) => a.art.clone(), + k => panic!("{name}: {k:?}"), + }; + let bounds = art.geometric_bounds().unwrap(); + assert_eq!((bounds.width(), bounds.height()), (4.0, 4.0), "{name}"); + assert!(matches!(art.kind, NodeKind::Path { .. }), "{name}: the frame left out: {art:#?}"); + let frame = Node::path(vectorcraft_doc::NodeId(90), vectorcraft_geom::shapes::rectangle(bounds), Default::default()); + let framed_art = Node::group(vectorcraft_doc::NodeId(91), vec![Arc::new(frame), Arc::new(art)]); + let kind = match &read.kind { + BrushKind::Scatter(s) => BrushKind::Scatter(Scatter { art: framed_art, ..s.clone() }), + BrushKind::Art(a) => BrushKind::Art(ArtBrush { art: framed_art, ..a.clone() }), + k => panic!("{name}: {k:?}"), + }; + let framed = Brush { kind, ..read.clone() }; + let mut line = kurbo::BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((100.0, 30.0)); + let stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 1.0); + let at = |b: &Brush| -> Vec<[i64; 4]> { + vectorcraft_brush::stroke_pieces(&r.doc, b, &line, &stroke) + .iter() + .filter_map(|n| n.geometric_bounds()) + .map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 1000.0).round() as i64)) + .collect() + }; + let (without, with) = (at(&read), at(&framed)); + assert!(!without.is_empty(), "{name}"); + if name == "Spray" { + assert!(without.len() > 10, "{without:?}"); + } + assert_eq!(without, with, "{name}"); + } +} + /// A file without brush objects keeps the default brushes. #[test] fn a_file_without_brushes_keeps_the_default_ones() { @@ -8552,13 +9257,31 @@ fn a_multi_layer_mask_in_a_group_or_sublayer() { #[test] fn a_multi_layer_masks_path_can_be_painted_or_compound() { - // `W f`: the path also paints, under what the mask covers. + // `W f`: the path also paints, once, under what the mask covers after it: its fill is a path of its own in the first + // layer, and the copies of the mask in each layer have no fill. + let black = Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)); let painted = format!("{}Mb\n0 0 0 1 k\n{}h\nW\nf\nMd\n{}MB\n{END7}", begin7("A", 1), rect_path(40.0, 40.0, 120.0, 120.0), masked(15.0)); let doc = read7(&painted).doc; assert_eq!(layer_trees(&doc)[0].1, "k[c p p]"); let group = doc.layers[0].children().unwrap()[0].clone(); let kids = group.children().unwrap(); - assert_eq!(fill(&kids[1]).color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the painted path first"); + assert_eq!(fill(&kids[0]), Paint::None, "the clipping path has no fill"); + assert_eq!(fill(&kids[1]).color(), black, "the painted path first"); + let two = format!( + "{}Mb\n0 0 0 1 k\n{}h\nW\nf\nMd\n{}{END7}{}{}MB\n{END7}", + begin7("A", 1), + rect_path(40.0, 40.0, 120.0, 120.0), + masked(15.0), + begin7("B", 1), + masked(30.0) + ); + let doc = read7(&two).doc; + assert_eq!(layer_trees(&doc), [("A".to_string(), "k[c p p]".to_string(), false), ("B".to_string(), "k[c p]".to_string(), false)]); + let kids = |l: usize| doc.layers[l].children().unwrap()[0].children().unwrap().to_vec(); + let (a, b) = (kids(0), kids(1)); + assert_eq!((fill(&a[0]), fill(&b[0])), (Paint::None, Paint::None), "no copy of the mask paints"); + assert_eq!(fill(&a[1]).color(), black); + assert_eq!(fill(&b[1]).color(), Some(Color::cmyk(0.5, 0.0, 0.0, 0.0)), "layer B's square, unpainted over"); // A compound path of clipping paths. let compound = format!( "{}Mb\n*u\n{}h\nW\nn\n{}h\nW\nn\n*U\nMd\n{}MB\n{END7}", @@ -8568,6 +9291,28 @@ fn a_multi_layer_masks_path_can_be_painted_or_compound() { masked(15.0) ); assert_eq!(layer_trees(&read7(&compound).doc)[0].1, "k[o p]"); + // A filled one over two layers: its fill is a compound path of its own in the first, whose paths don't clip. + let filled = format!( + "{}Mb\n0 0 0 1 k\n*u\n{}h\nW\nf\n{}h\nW\nf\n*U\nMd\n{}{END7}{}{}MB\n{END7}", + begin7("A", 1), + rect_path(40.0, 40.0, 80.0, 80.0), + rect_path(90.0, 90.0, 120.0, 120.0), + masked(15.0), + begin7("B", 1), + masked(30.0) + ); + let doc = read7(&filled).doc; + assert_eq!(layer_trees(&doc), [("A".to_string(), "k[o o p]".to_string(), false), ("B".to_string(), "k[o p]".to_string(), false)]); + let kids = |l: usize| doc.layers[l].children().unwrap()[0].children().unwrap().to_vec(); + let (a, b) = (kids(0), kids(1)); + assert_eq!((fill(&a[0]), fill(&b[0])), (Paint::None, Paint::None), "no copy of the mask paints"); + assert_eq!(fill(&a[1]).color(), black); + let NodeKind::Compound { children: subpaths, .. } = &a[1].kind else { panic!("{:?}", a[1].kind) }; + assert!(subpaths.iter().all(|p| matches!(p.kind, NodeKind::Path { clipping: false, .. })), "{subpaths:#?}"); + let mut ids = std::collections::HashSet::new(); + for l in &doc.layers { + l.walk(&mut |n| assert!(ids.insert(n.id), "{:?} appears twice", n.id)); + } } /// `Mv` starts a mask as `Mb` does, `ME` defines one as `Md` does, and `Mi` changes nothing. @@ -8650,4 +9395,8 @@ fn copies_of_a_multi_layer_mask_count_toward_the_limit() { let two = format!("{}{mask}{}{END7}{}{}MB\n{END7}", begin7("A", 1), masked(15.0), begin7("B", 1), masked(30.0)); let e = read(mask_stream7(&two).as_bytes()).unwrap_err(); assert!(e.contains("objects"), "{e}"); + // A filled mask's fill, a path of its own, counts too. + let filled = format!("{}{}{}MB\n{END7}", begin7("A", 1), mask.replace("W\nn\n", "W\nf\n"), masked(15.0)); + let e = read(mask_stream7(&filled).as_bytes()).unwrap_err(); + assert!(e.contains("objects"), "{e}"); } diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 1e2347249..bb96b3d27 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -5,12 +5,13 @@ //! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object //! in a layer is only a stub naming its story in it (`/StoryIndex`). //! -//! The format isn't documented. What is read here was worked out from the files of the project's -//! users, used locally and never committed, by comparing it with the type their pages draw: the -//! meaning of each key below was checked against the page's own type in more than a thousand text -//! objects, and it is only used for the keys that matched. Point type, area type and type on a path -//! are read; a story in a frame of any other kind is left alone: it has no text, and the importer -//! says so. +//! The format isn't documented. Most of what is read here was worked out from the files of the +//! project's users, used locally and never committed, by comparing it with the type their pages +//! draw: the meaning of those keys was checked against the page's own type in more than a thousand +//! text objects, and it is only used for the keys that matched. The Type on a Path Options of key +//! 11 of a frame come from test files saved with one option changed at a time, used locally and +//! never committed. Point type, area type and type on a path are read; a story in a frame of any +//! other kind is left alone: it has no text, and the importer says so. //! //! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of //! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an @@ -24,7 +25,7 @@ use std::collections::BTreeMap; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, TextRun}; use vectorcraft_geom::{Affine, PathData, Point, Rect}; /// Longest story read (bytes). @@ -254,7 +255,46 @@ enum FrameKind { /// Area Type Options the frame keeps. Area(Vec<[f64; 8]>, AreaFrame), /// Type on a path, from `start` to `end` (segment index and the place in that segment). - OnPath { segments: Vec<[f64; 8]>, start: f64, end: f64 }, + OnPath { segments: Vec<[f64; 8]>, start: f64, end: f64, options: PathFrame }, +} + +/// The Type on a Path Options a frame of type on a path keeps at key 11. Each stays at its default +/// where the frame leaves the key out or keeps a value that can't be read. A frame with key 6 true +/// keeps its defaults: what that state stores for these options isn't known. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +struct PathFrame { + /// Key 1: 1 Skew, 2 3D Ribbon, 3 Stair Step, 4 Gravity. Rainbow is the key left out. + effect: PathEffect, + /// Key 2: 0 Ascender, 1 Descender, 2 Center. Baseline is the key left out. + align: PathAlign, + /// Key 0: the type is flipped across its path. The frame's path runs as it did before the flip, + /// from the start of its range. + flip: bool, +} + +impl PathFrame { + /// The options frame data `carries` keeps. + fn read(carries: &Val) -> PathFrame { + let Some(options) = carries.get("11") else { return PathFrame::default() }; + if matches!(options.get("6"), Some(Val::Bool(true))) { + return PathFrame::default(); + } + let code = |k: &str| options.get(k).and_then(Val::num); + let effect = match code("1") { + Some(1.0) => PathEffect::Skew, + Some(2.0) => PathEffect::Ribbon3d, + Some(3.0) => PathEffect::StairStep, + Some(4.0) => PathEffect::Gravity, + _ => PathEffect::Rainbow, + }; + let align = match code("2") { + Some(0.0) => PathAlign::Ascender, + Some(1.0) => PathAlign::Descender, + Some(2.0) => PathAlign::Center, + _ => PathAlign::Baseline, + }; + PathFrame { effect, align, flip: matches!(options.get("0"), Some(Val::Bool(true))) } + } } /// The Area Type Options an area frame keeps, in the units of the text document: each `None` @@ -484,7 +524,7 @@ impl Texts { let start = finite(range.first()).unwrap_or(0.0).clamp(0.0, n); (start, finite(range.get(1)).unwrap_or(n).clamp(start, n)) }; - Some(FrameKind::OnPath { segments, start, end }) + Some(FrameKind::OnPath { segments, start, end, options: PathFrame::read(carries) }) } _ => None, } @@ -527,8 +567,10 @@ impl Texts { if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) { // Vertical type on a path turns its glyphs across the path: their matrices don't give // the spacing. - if matches!(frames.first(), Some(FrameKind::OnPath { .. })) && !vertical { - path_spacing = self::path_spacing(f); + if let Some(FrameKind::OnPath { options, .. }) = frames.first() + && !vertical + { + path_spacing = self::path_spacing(f, options.effect); } if let Some(a) = f.get("0").and_then(|a| a.get("0")).and_then(Val::nums) && let [x, y, ..] = a.as_slice() @@ -721,16 +763,32 @@ fn length_fraction(segments: &[[f64; 8]], t: f64) -> f64 { /// (`vectorcraft_text`'s `path_steps`): between two glyphs' centres, the path's length `s`, their /// advances' mean `a` and how far it turns `t` (away from their tops) make `s + t × spacing = a`. /// Fitted over every pair of the layout's glyphs; none where the path doesn't turn under them. -fn path_spacing(f: &Val) -> f64 { +/// The length `s` is on the path: Align to Path moves the baselines off it, by the distance `G` +/// keeps. Under 3D Ribbon the glyphs' baselines stay level, so their feet show how the path turns. +fn path_spacing(f: &Val, effect: PathEffect) -> f64 { let mut found = vec![]; find_nodes(f, "G", &mut found); let (mut sum, mut turns) = (0.0, 0.0); for g in found { + // How far the baseline lies from the path, towards the glyphs' feet. Only Rainbow's and 3D + // Ribbon's feet are square to the path; Skew's are upright, so its alignment moves every + // baseline the same way and leaves the distances between them. + let offset = if matches!(effect, PathEffect::Rainbow | PathEffect::Ribbon3d) { + g.get("0").and_then(|o| o.get("0")).and_then(Val::nums).and_then(|o| o.get(1).copied()).filter(|o| o.is_finite()).unwrap_or(0.0) + } else { + 0.0 + }; // Each glyph's origin, its matrix (along its baseline, then towards its foot) and where it // ends along the line; the lists end with where the line ends. let Some(at) = g.get("21") else { continue }; let list = |k: &str| at.get(k).and_then(Val::nums).unwrap_or_default(); let (xs, ys, matrices, ends) = (list("1"), list("2"), list("3"), list("4")); + if effect == PathEffect::StairStep { + let (s, t) = stair_step_fit(&xs, &ys, &ends); + sum += s; + turns += t; + continue; + } let glyphs: Vec<(Point, f64, kurbo::Vec2, kurbo::Vec2)> = xs .iter() .zip(&ys) @@ -743,13 +801,30 @@ fn path_spacing(f: &Val) -> f64 { }) .collect(); for w in glyphs.windows(2) { - let [(p, a0, along, foot), (q, a1, next, _)] = w else { continue }; - let turn = next.dot(*foot).atan2(next.dot(*along)); + let [(p, a0, along, foot), (q, a1, next, next_foot)] = w else { continue }; + // 3D Ribbon's level baseline faces the way the path runs, so where the path runs left its + // baseline and foot are turned round: `side` turns them back. + let side = |baseline: &kurbo::Vec2| if effect == PathEffect::Ribbon3d && baseline.x < 0.0 { -1.0 } else { 1.0 }; + let (side, next_side) = (side(along), side(next)); + let (along, next) = if effect == PathEffect::Ribbon3d { + (kurbo::Vec2::new(foot.y, -foot.x) * side, kurbo::Vec2::new(next_foot.y, -next_foot.x) * next_side) + } else { + (*along, *next) + }; + // The turn is measured against a foot square to the path: Rainbow's and 3D Ribbon's own, + // else the baseline turned a right angle, as theirs is (Skew's feet stay upright). + let square = + if matches!(effect, PathEffect::Rainbow | PathEffect::Ribbon3d) { *foot * side } else { kurbo::Vec2::new(-along.y, along.x) }; + let turn = next.dot(square).atan2(next.dot(along)); // The length of the arc between the centres (the path is taken to be round there). A - // corner (a turn of more than a radian between two glyphs) isn't round: left out. + // corner (a turn of more than a radian between two glyphs) isn't round: left out. So is a + // 3D Ribbon pair either side of where the path turns back, when an alignment puts their + // baselines on opposite sides of the path. let half = turn.abs() * 0.5; - let length = if half > 1e-9 { (*q - *p).hypot() * half / half.sin() } else { (*q - *p).hypot() }; - if turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() { + let chord = (*q - *p).hypot(); + let length = if half > 1e-9 { chord * half / half.sin() } else { chord } + turn * offset * side; + let apart = side != next_side && offset != 0.0; + if !apart && turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() { sum += ((a0 + a1) * 0.5 - length) * turn; turns += turn * turn; } @@ -759,6 +834,45 @@ fn path_spacing(f: &Val) -> f64 { if turns > 1e-6 && spacing.is_finite() { spacing.clamp(-1000.0, 1000.0) } else { 0.0 } } +/// [`path_spacing`]'s sums for one line of Stair Step type, from its glyphs' origins `xs`, `ys` and +/// where they end along the line, `ends`. Stair Step's glyphs stay upright, so their matrices don't +/// show how the path turns; each origin lies on the path (all of them moved the same way by an +/// alignment), and a glyph's step is the path's length from its origin to the next. The path is taken +/// to be round through three origins in a row, which gives how far it turns over that step `t`; the +/// text engine makes the step `s` with `s + t × spacing = a`, `a` the glyph's advance. +fn stair_step_fit(xs: &[f64], ys: &[f64], ends: &[f64]) -> (f64, f64) { + let origins: Vec = xs.iter().zip(ys).map(|(x, y)| Point::new(*x, *y)).collect(); + let (mut sum, mut turns) = (0.0, 0.0); + for i in 0..origins.len().saturating_sub(1) { + let (Some(p), Some(q)) = (origins.get(i), origins.get(i + 1)) else { continue }; + // The third origin: the one before, else the one after. + let triple = match i.checked_sub(1).and_then(|h| origins.get(h)) { + Some(o) => (*o, *p, *q, *o), + None => match origins.get(i + 2) { + Some(r) => (*p, *q, *r, *r), + None => continue, + }, + }; + let (a, b, c, third) = triple; + // The inscribed angle at the third origin is half the arc's angle from `p` to `q`. + let (to_p, to_q) = ((*p - third).hypot(), (*q - third).hypot()); + let sine = (b - a).cross(c - a) / (to_p * to_q); + if !(sine.is_finite() && sine.abs() < 1.0) { + continue; + } + let turn = 2.0 * sine.asin(); + let half = turn.abs() * 0.5; + let chord = (*q - *p).hypot(); + let length = if half > 1e-9 { chord * half / half.sin() } else { chord }; + let advance = ends.get(i + 1).zip(ends.get(i)).map_or(f64::NAN, |(e1, e0)| e1 - e0); + if turn.abs() <= 1.0 && length.is_finite() && advance.is_finite() { + sum += (advance - length) * turn; + turns += turn * turn; + } + } + (sum, turns) +} + impl Story { /// How many bytes of text the story has. pub(super) fn len(&self) -> usize { @@ -850,11 +964,12 @@ impl Story { (scale, Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]), TextKind::Point) } FrameKind::Area(s, _) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }), - FrameKind::OnPath { segments, start, end } => { + FrameKind::OnPath { segments, start, end, options } => { let n = segments.len() as f64; // A closed path keeps its end bracket wherever it is (the same place as the start: a - // full turn, see `TextKind::path_span`); an open one's end at its end is none. - let end = (closed(segments) || *end < n - 0.02).then(|| length_fraction(segments, *end)); + // full turn, see `TextKind::path_span`); an open one's end at its end is none, unless + // a flip makes it the place the type starts. + let end = (closed(segments) || options.flip || *end < n - 0.02).then(|| length_fraction(segments, *end)); (1.0, Affine::IDENTITY, TextKind::OnPath { path: path_of(segments, template, to_doc), start: length_fraction(segments, *start), end }) } }; @@ -913,8 +1028,13 @@ impl Story { t.path_spacing = self.path_spacing * scale; } // Each frame of a thread keeps its own options. - if let Some(FrameKind::Area(_, options)) = self.frames.get(frame) { - options.apply(&mut t.area, scale); + match self.frames.get(frame) { + Some(FrameKind::Area(_, options)) => options.apply(&mut t.area, scale), + Some(FrameKind::OnPath { options, .. }) => { + t.path_effect = options.effect; + t.path_align = options.align; + } + _ => {} } t.vertical = self.vertical; t.kind = kind; @@ -922,6 +1042,17 @@ impl Story { Some(Node::new(id, NodeKind::Text(Box::new(t)))) } + /// Flips type on a path (`node`, made from frame `frame`) when its frame keeps Flip on. The + /// frame's path runs as it did before the flip, so this runs once, on the path the object ends + /// up with. + pub(super) fn flip_on_path(&self, node: &mut Node, frame: usize) { + if let Some(FrameKind::OnPath { options: PathFrame { flip: true, .. }, .. }) = self.frames.get(frame) + && let NodeKind::Text(t) = &mut node.kind + { + t.flip_on_path(); + } + } + /// Area type that the file lays lines out in, in a frame too short for its first line as it is /// laid out here: a font that isn't available shows in the fallback font, whose lines may be /// taller, and type that doesn't fit its frame isn't drawn. The frame is made taller, from its @@ -1241,9 +1372,14 @@ mod tests { /// A text document's frame on a path of `segments` (each `x0 y0 x1 y1 x2 y2 x3 y3`) with the /// range `range`, written by hand. fn on_path_frame(segments: &[[f64; 8]], range: [f64; 2]) -> Option { + on_path_frame_with(segments, range, "") + } + + /// The same with `options` in the frame data too. + fn on_path_frame_with(segments: &[[f64; 8]], range: [f64; 2], options: &str) -> Option { let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::>().join(" "); let all: Vec = segments.iter().flatten().copied().collect(); - let doc = format!("/0 << /1 << /0 [ {} ] >> /2 << /0 2 /6 [ {} ] >> >>", nums(&all), nums(&range)); + let doc = format!("/0 << /1 << /0 [ {} ] >> /2 << /0 2 /6 [ {} ] {options} >> >>", nums(&all), nums(&range)); let texts = Texts { stories: vec![], fonts: vec![], @@ -1277,6 +1413,123 @@ mod tests { (*t, b - a) } + /// Key 11's codes for the effect and the alignment, each read on its own. The defaults are the + /// keys left out. + #[test] + fn type_on_a_path_keeps_its_effect_and_alignment() { + use PathAlign as A; + use PathEffect as E; + let read = |options: &str| { + let (t, _) = on_path("type", on_path_frame_with(&circle(), [0.0, 3.99], options).unwrap()); + (t.path_effect, t.path_align) + }; + assert_eq!(read(""), (E::Rainbow, A::Baseline)); + assert_eq!(read("/11 << /4 21 /18 21.0 /6 false /22 0.025 >>"), (E::Rainbow, A::Baseline)); + for (code, effect) in [(1, E::Skew), (2, E::Ribbon3d), (3, E::StairStep), (4, E::Gravity)] { + assert_eq!(read(&format!("/11 << /1 {code} >>")), (effect, A::Baseline), "effect {code}"); + } + for (code, align) in [(0, A::Ascender), (1, A::Descender), (2, A::Center)] { + assert_eq!(read(&format!("/11 << /2 {code} >>")), (E::Rainbow, align), "alignment {code}"); + } + assert_eq!(read("/11 << /1 3 /2 1 >>"), (E::StairStep, A::Descender)); + } + + /// A frame with key 6 true keeps the defaults, Flip included. + #[test] + fn type_on_a_path_with_key_6_true_keeps_the_defaults() { + let Some(FrameKind::OnPath { options, .. }) = on_path_frame_with(&circle(), [0.0, 3.99], "/11 << /6 true /0 true /1 1 /2 0 >>") else { + panic!("on a path") + }; + assert_eq!(options, PathFrame::default()); + } + + /// Key 11 where it can't be read leaves the options at their defaults, each on its own. + #[test] + fn type_on_a_path_options_that_cannot_be_read_keep_their_defaults() { + let bad = ["5", "-1", "1.5", "1e300", "(1)", "true", "/Skew", "[ 1 ]", "<< /0 1 >>"]; + for value in bad { + for key in ["1", "2", "0"] { + let Some(FrameKind::OnPath { options, .. }) = on_path_frame_with(&circle(), [0.0, 3.99], &format!("/11 << /{key} {value} >>")) else { + panic!("on a path") + }; + let expected = PathFrame { flip: key == "0" && value == "true", ..PathFrame::default() }; + assert_eq!(options, expected, "key {key} value {value}"); + } + } + for whole in ["3", "true", "(x)", "[ /1 1 ]"] { + let Some(FrameKind::OnPath { options, .. }) = on_path_frame_with(&circle(), [0.0, 3.99], &format!("/11 {whole}")) else { + panic!("on a path") + }; + assert_eq!(options, PathFrame::default(), "{whole}"); + } + // One option that can't be read leaves the other as the frame keeps it. + let Some(FrameKind::OnPath { options, .. }) = on_path_frame_with(&circle(), [0.0, 3.99], "/11 << /1 9 /2 2 >>") else { panic!("on a path") }; + assert_eq!((options.effect, options.align), (PathEffect::Rainbow, PathAlign::Center)); + } + + /// The first point of type on a path's path, and its span. + fn flipped_on_path(frames: Vec, frame: usize) -> (f64, (f64, f64)) { + let mut st = story("type", &[0.0], &Para::default()); + st.frames = frames; + let mut node = st.node(NodeId(1), frame, (0.0, 0.0), Affine::IDENTITY).unwrap(); + st.flip_on_path(&mut node, frame); + let NodeKind::Text(t) = node.kind else { panic!("not type") }; + let vectorcraft_doc::TextKind::OnPath { path, .. } = &t.kind else { panic!("type on a path") }; + let Some(kurbo::PathEl::MoveTo(first)) = path.to_bezpath().elements().first().copied() else { panic!("a path") }; + (first.x + CANVAS_CENTRE, t.kind.path_span().unwrap()) + } + + /// Flip is a flag: the frame's path and range run as before the flip, so the type flips once, + /// keeps its stretch of the path, and the path runs from its far end. + #[test] + fn flipped_type_on_a_path_runs_the_path_the_other_way_once() { + let open = [[0.0, 0.0, 30.0, 0.0, 70.0, 0.0, 100.0, 0.0], [100.0, 0.0, 130.0, 0.0, 170.0, 0.0, 200.0, 0.0]]; + let frame = |options: &str| vec![on_path_frame_with(&open, [0.5, 1.5], options).unwrap()]; + let (first, (from, to)) = flipped_on_path(frame("/11 << /0 false >>"), 0); + assert_eq!(first, 0.0); + assert!((from - 0.25).abs() < 1e-3 && (to - 0.75).abs() < 1e-3, "{from} {to}"); + let (first, (from, to)) = flipped_on_path(frame("/11 << /0 true >>"), 0); + assert_eq!(first, 200.0, "from the path's far end"); + assert!((from - 0.25).abs() < 1e-3 && (to - 0.75).abs() < 1e-3, "the same stretch: {from} {to}"); + } + + /// The end of an open path's range, which doesn't move unflipped type, is where flipped type + /// starts: a range that ends just short of the path's end starts the type that far from it. + #[test] + fn flipped_type_on_an_open_path_starts_at_the_end_of_its_range() { + let third = 100.0 / 3.0; + let line = [[0.0, 0.0, third, 0.0, 2.0 * third, 0.0, 100.0, 0.0]]; + let frame = |options: &str| vec![on_path_frame_with(&line, [0.0, 0.99], options).unwrap()]; + let (_, (from, to)) = flipped_on_path(frame("/11 << /0 true >>"), 0); + assert!((from - 0.01).abs() < 1e-3 && (to - 1.0).abs() < 1e-3, "{from} {to}"); + let (_, (from, to)) = flipped_on_path(frame(""), 0); + assert!(from.abs() < 1e-3 && (to - 1.0).abs() < 1e-3, "unflipped: {from} {to}"); + } + + /// Round a closed path, a flip keeps the stretch too, and each frame of a thread keeps its own + /// options. + #[test] + fn each_frame_of_type_on_a_path_keeps_its_options() { + let mut st = story("type", &[0.0], &Para::default()); + st.frames = vec![ + on_path_frame_with(&circle(), [0.5, 2.5], "/11 << /0 true /1 1 >>").unwrap(), + on_path_frame_with(&circle(), [0.0, 3.99], "/11 << /2 0 >>").unwrap(), + ]; + let mut first = st.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap(); + let mut second = st.node(NodeId(2), 1, (0.0, 0.0), Affine::IDENTITY).unwrap(); + let before = first.clone(); + st.flip_on_path(&mut first, 0); + assert_ne!(before, first, "the first frame flips"); + let before = second.clone(); + st.flip_on_path(&mut second, 1); + assert_eq!(before, second, "the second frame doesn't"); + let (NodeKind::Text(a), NodeKind::Text(b)) = (&first.kind, &second.kind) else { panic!("not type") }; + assert_eq!((a.path_effect, a.path_align), (PathEffect::Skew, PathAlign::Baseline)); + assert_eq!((b.path_effect, b.path_align), (PathEffect::Rainbow, PathAlign::Ascender)); + let (from, to) = a.kind.path_span().unwrap(); + assert!((to - from - 0.5).abs() < 1e-3, "{from} {to}"); + } + /// Round a closed path the file writes the end bracket unwrapped, past the last segment (the /// start plus the run): the type keeps that run, nearly the whole circle here, not the bit /// from the start to the path's end. @@ -1355,6 +1608,12 @@ mod tests { /// outside it (their feet towards its centre) or inside it, the length of the circle between /// their centres `fraction` of their advances' mean. fn round_layout(advances: &[f64], radius: f64, outside: bool, fraction: f64) -> Val { + round_layout_with(advances, radius, outside, fraction, "", false) + } + + /// The same with `extra` in the line's `G`, and baselines that stay level as 3D Ribbon's do when + /// `level`. + fn round_layout_with(advances: &[f64], radius: f64, outside: bool, fraction: f64, extra: &str, level: bool) -> Val { let sign = if outside { 1.0 } else { -1.0 }; let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![0.0]); let mut angle = std::f64::consts::PI; @@ -1365,14 +1624,15 @@ mod tests { // Outside, clockwise on the page (y down); inside, the other way round. let along = (-angle.sin() * sign, angle.cos() * sign); let centre = (radius * angle.cos(), radius * angle.sin()); - xs.push(centre.0 - along.0 * a * 0.5); - ys.push(centre.1 - along.1 * a * 0.5); - matrices.extend([along.0, along.1, -along.1, along.0, 0.0, 0.0]); + let baseline = if level { (1.0, 0.0) } else { along }; + xs.push(centre.0 - baseline.0 * a * 0.5); + ys.push(centre.1 - baseline.1 * a * 0.5); + matrices.extend([baseline.0, baseline.1, -along.1, along.0, 0.0, 0.0]); ends.push(ends[i] + a); } let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::>().join(" "); let doc = format!( - "/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>", + "/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G {extra} /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>", nums(&xs), nums(&ys), nums(&matrices), @@ -1388,18 +1648,194 @@ mod tests { fn the_layout_of_type_on_a_path_gives_its_spacing() { let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; // Outside a circle of radius 100, 10 above the path: the centres are 100/110 of the advances apart. - let spacing = path_spacing(&round_layout(&advances, 100.0, true, 100.0 / 110.0)); + let rainbow = PathEffect::Rainbow; + let spacing = path_spacing(&round_layout(&advances, 100.0, true, 100.0 / 110.0), rainbow); assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); // Inside it, 20 above the path: 100/80 of them. - let spacing = path_spacing(&round_layout(&advances, 100.0, false, 100.0 / 80.0)); + let spacing = path_spacing(&round_layout(&advances, 100.0, false, 100.0 / 80.0), rainbow); assert!((spacing - 20.0).abs() < 1e-6, "{spacing}"); // At their advances: none. - assert!(path_spacing(&round_layout(&advances, 100.0, true, 1.0)).abs() < 1e-6); + assert!(path_spacing(&round_layout(&advances, 100.0, true, 1.0), rainbow).abs() < 1e-6); // Corners (here a turn of about four radians from glyph to glyph) aren't taken for a curve. - assert_eq!(path_spacing(&round_layout(&advances, 5.0, true, 1.0)), 0.0); + assert_eq!(path_spacing(&round_layout(&advances, 5.0, true, 1.0), rainbow), 0.0); // A straight line turns nowhere, and a layout without glyphs says nothing. - assert_eq!(path_spacing(&round_layout(&advances, 1e12, true, 0.9)), 0.0); - assert_eq!(path_spacing(&read(b"/99 /F").unwrap()), 0.0); + assert_eq!(path_spacing(&round_layout(&advances, 1e12, true, 0.9), rainbow), 0.0); + assert_eq!(path_spacing(&read(b"/99 /F").unwrap(), rainbow), 0.0); + } + + /// Align to Path moves the baselines off the path, and the spacing is the one on the path. On a + /// circle of radius 100 with spacing 10, the path's length between two centres is 100/110 of + /// their advances. Baselines on a smaller or larger circle are that much closer or further. + #[test] + fn the_spacing_of_aligned_type_is_measured_on_its_path() { + let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; + let fit = + |radius: f64, extra: &str| path_spacing(&round_layout_with(&advances, radius, true, radius / 110.0, extra, false), PathEffect::Rainbow); + // 20 towards the feet: Ascender. + let spacing = fit(80.0, "/0 << /0 [ 0 20 ] >>"); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); + // 8 away from them: Descender. + let spacing = fit(108.0, "/0 << /0 [ 0 -8 ] >>"); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); + // A distance that can't be read counts as none. + for bad in ["/0 3", "/0 << /0 (x) >>", "/0 << /0 [ 0 ] >>"] { + let spacing = fit(100.0, bad); + assert!((spacing - 10.0).abs() < 1e-6, "{bad}: {spacing}"); + } + // Skew's feet are upright: its alignment moves the baselines without changing their + // lengths, so the distance doesn't count. + let layout = |extra: &str| round_layout_with(&advances, 100.0, true, 100.0 / 110.0, extra, false); + let plain = path_spacing(&layout(""), PathEffect::Skew); + let aligned = path_spacing(&layout("/0 << /0 [ 0 20 ] >>"), PathEffect::Skew); + assert!((aligned - plain).abs() < 1e-9, "{plain} {aligned}"); + // 3D Ribbon's feet are square to the path, as Rainbow's are. + let level = round_layout_with(&advances, 80.0, true, 80.0 / 110.0, "/0 << /0 [ 0 20 ] >>", true); + let spacing = path_spacing(&level, PathEffect::Ribbon3d); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); + } + + /// Skew's feet stay upright, so on a straight path at 30 degrees they are not square to it. The + /// path turns nowhere, so glyphs set further apart than their advances give no spacing, going + /// right or left. + #[test] + fn skewed_type_on_a_straight_tilted_path_has_no_spacing() { + let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; + for degrees in [30.0f64, 210.0] { + let along = kurbo::Vec2::new(degrees.to_radians().cos(), degrees.to_radians().sin()); + let (mut at, mut glyphs) = (0.0, vec![]); + for (i, a) in advances.iter().enumerate() { + if i > 0 { + at += (advances[i - 1] + a) * 0.5 * 1.2; + } + glyphs.push((Point::ORIGIN + along * at, along)); + } + let layout = upright_layout(&advances, &glyphs); + for effect in [PathEffect::Skew, PathEffect::StairStep, PathEffect::Gravity] { + let spacing = path_spacing(&layout, effect); + assert!(spacing.abs() < 1e-9, "{degrees}, {effect:?}: {spacing}"); + } + } + } + + /// Round the bottom of a circle the path runs left, and Skew's upright feet point away from the + /// side the path turns to: the turn is still measured as Rainbow's is. Outside a circle of + /// radius 100, 10 above the path, the centres are 100/110 of the advances apart: spacing 10. + #[test] + fn skewed_type_running_left_round_a_curve_gives_its_spacing() { + let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; + let mut angle = std::f64::consts::FRAC_PI_2 - 0.2; + let mut glyphs = vec![]; + for (i, a) in advances.iter().enumerate() { + if i > 0 { + angle += (advances[i - 1] + a) * 0.5 * (100.0 / 110.0) / 100.0; + } + glyphs.push((Point::new(100.0 * angle.cos(), 100.0 * angle.sin()), kurbo::Vec2::new(-angle.sin(), angle.cos()))); + } + let spacing = path_spacing(&upright_layout(&advances, &glyphs), PathEffect::Skew); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); + } + + /// A line's layout whose glyphs have their centres and baselines `glyphs` and upright feet, as + /// Skew's are. + fn upright_layout(advances: &[f64], glyphs: &[(Point, kurbo::Vec2)]) -> Val { + let glyphs: Vec<(Point, [f64; 4])> = glyphs.iter().map(|(centre, along)| (*centre, [along.x, along.y, 0.0, 1.0])).collect(); + line_layout(advances, &glyphs, "") + } + + /// A line's layout, `extra` in its `G`, whose glyphs have their centres and matrices `glyphs`. + fn line_layout(advances: &[f64], glyphs: &[(Point, [f64; 4])], extra: &str) -> Val { + let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![0.0]); + for (i, (a, (centre, [m0, m1, m2, m3]))) in advances.iter().zip(glyphs).enumerate() { + xs.push(centre.x - m0 * a * 0.5); + ys.push(centre.y - m1 * a * 0.5); + matrices.extend([*m0, *m1, *m2, *m3, 0.0, 0.0]); + ends.push(ends[i] + a); + } + let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::>().join(" "); + let doc = format!( + "/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G {extra} /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>", + nums(&xs), + nums(&ys), + nums(&matrices), + nums(&ends) + ); + read(doc.as_bytes()).unwrap().get("0").unwrap().clone() + } + + /// 3D Ribbon keeps its baselines level, so its glyphs' feet show how the path turns. + #[test] + fn the_spacing_of_3d_ribbon_type_follows_the_feet_of_its_glyphs() { + let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; + let layout = round_layout_with(&advances, 100.0, true, 100.0 / 110.0, "", true); + let spacing = path_spacing(&layout, PathEffect::Ribbon3d); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); + } + + /// Where the path runs left, 3D Ribbon's level baseline faces left and its foot turns round with + /// it. Round a circle of radius 100 through the place where the path runs straight up, the glyphs + /// on either side of it are still read as following one curve: spacing 10. + #[test] + fn the_spacing_of_3d_ribbon_type_holds_where_the_path_turns_back() { + let advances = [20.0, 30.0]; + let mut angle = std::f64::consts::PI - 0.1; + let mut glyphs = vec![]; + for (i, a) in advances.iter().enumerate() { + if i > 0 { + angle += (advances[i - 1] + a) * 0.5 * (100.0 / 110.0) / 100.0; + } + let along = kurbo::Vec2::new(-angle.sin(), angle.cos()); + let side = if along.x < 0.0 { -1.0 } else { 1.0 }; + glyphs.push((Point::new(100.0 * angle.cos(), 100.0 * angle.sin()), [side, 0.0, -along.y * side, along.x * side])); + } + assert!(glyphs.first().is_some_and(|(_, m)| m[0] < 0.0) && glyphs.last().is_some_and(|(_, m)| m[0] > 0.0)); + let spacing = path_spacing(&line_layout(&advances, &glyphs, ""), PathEffect::Ribbon3d); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); + } + + /// Stair Step's glyphs stay upright, each origin on the path, its step the path's length to the + /// next. On a circle of radius 100 with spacing 10, each step is 100/110 of the glyph's advance; + /// moved 20 down by an alignment, the origins give the same spacing. + #[test] + fn the_spacing_of_stair_step_type_follows_the_origins_of_its_glyphs() { + let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; + for rise in [0.0, 20.0] { + let mut s = 0.0; + let mut glyphs = vec![]; + for a in advances { + let angle = std::f64::consts::PI + s / 100.0; + // `line_layout` takes centres: half an advance along the level baseline. + glyphs.push((Point::new(100.0 * angle.cos() + a * 0.5, 100.0 * angle.sin() + rise), [1.0, 0.0, 0.0, 1.0])); + s += a * 100.0 / 110.0; + } + let spacing = path_spacing(&line_layout(&advances, &glyphs, ""), PathEffect::StairStep); + assert!((spacing - 10.0).abs() < 1e-6, "{rise}: {spacing}"); + } + // Two glyphs say nothing of how the path turns. + let two = line_layout(&advances[..2], &[(Point::new(0.0, 0.0), [1.0, 0.0, 0.0, 1.0]), (Point::new(30.0, 5.0), [1.0, 0.0, 0.0, 1.0])], ""); + assert_eq!(path_spacing(&two, PathEffect::StairStep), 0.0); + } + + /// With Ascender (baselines 20 towards the feet), glyphs facing left lie outside the path and + /// glyphs facing right inside it. The pair either side of where the path runs straight up is left + /// out, and the others give spacing 10. + #[test] + fn aligned_3d_ribbon_type_leaves_out_the_pair_where_the_path_turns_back() { + let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; + let mut angle = std::f64::consts::PI - 0.5; + let mut glyphs = vec![]; + for (i, a) in advances.iter().enumerate() { + if i > 0 { + angle += (advances[i - 1] + a) * 0.5 / 110.0; + } + let along = kurbo::Vec2::new(-angle.sin(), angle.cos()); + let side = if along.x < 0.0 { -1.0 } else { 1.0 }; + let radius = 100.0 - 20.0 * side; + glyphs.push((Point::new(radius * angle.cos(), radius * angle.sin()), [side, 0.0, -along.y * side, along.x * side])); + } + let sides: Vec = glyphs.iter().map(|(_, m)| m[0]).collect(); + assert_eq!(sides, [-1.0, -1.0, -1.0, 1.0, 1.0]); + let spacing = path_spacing(&line_layout(&advances, &glyphs, "/0 << /0 [ 0 20 ] >>"), PathEffect::Ribbon3d); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); } /// The leading of the text object a story comes in as. diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index f78bcc8a8..a7858e46b 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -54,7 +54,7 @@ use build::{Frame, MADE, prune_unseen}; use compare::{ MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour, }; -pub use layers::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, layered_ai, layered_ai_from}; +pub use layers::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, layered_ai, layered_ai_from}; pub(super) use layers::{layered, program_alone}; pub(crate) use slots::MAX_TEXT; pub use slots::is_loss; diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 698201be5..bdb99e94c 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -209,9 +209,10 @@ pub(super) fn build_from( xml_ids, bindings, bindings_skipped, + warped_type, warnings, } = structure; - let restyled = left_out_of_comparisons(page, restyled, placeholders); + let mut restyled = left_out_of_comparisons(page, restyled, placeholders); // Editing data in the units of the page's user space ([`in_units`]), or at its declared large // canvas scale ([`page_scale`]), is compared with the page scaled down to those units, and the // document read from it is scaled up by `unit`. @@ -284,16 +285,20 @@ pub(super) fn build_from( lone.push(vec![Arc::new(n.clone())]); } }); - // A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each - // goes where the layers have that artboard. + // A `.ai` read as its PDF part has a page for each artboard it was saved with, in a row: + // the type on each goes where the layers have that artboard. let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); - let boards = page_boards(&doc.artboards, first, &pages); + let boards = page_boards(&doc, visible, first, &pages); + // The first page's type goes to the first artboard only when that artboard is the page's + // (every artboard has a page); otherwise it goes to `rect`, the art's box or the editing + // data's artboard. + let first_board = first > 0 || pages.len() == doc.artboards.len(); + let board_of = |i: usize| boards.as_ref()?.get(i).filter(|_| i > 0 || first_board); let page_moves: Vec<(Rect, Affine)> = pages .iter() .enumerate() .map(|(i, page)| { - let move_to_doc = - boards.and_then(|boards| boards.get(i)).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift); + let move_to_doc = board_of(i).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift); (*page, move_to_doc) }) .collect(); @@ -312,7 +317,7 @@ pub(super) fn build_from( } for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) { let on = page_of(object); - let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) { + let shift = match on.and_then(|(i, page)| Some((board_of(i)?, page))) { Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()), None => shift, }; @@ -322,7 +327,7 @@ pub(super) fn build_from( } // Pages cropped to the art are copies of the first, and a PDF saved with some of the // artboards can hold any of them. - let more = more_pages(boards.filter(|b| !cropped && b.len() == doc.artboards.len()), &pages, comparison_samples(visible)); + let more = more_pages(boards.as_deref().filter(|b| !cropped && b.len() == doc.artboards.len()), &pages, comparison_samples(visible)); // An EPS saved as its artboard has the art outside it left out of its page. let artboard_page = artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); @@ -412,6 +417,8 @@ pub(super) fn build_from( } // Area type flows around the wrap objects above it before it is compared with the page. resolve_text_wrap(&mut doc)?; + let bound_text = text_bound_slots(&doc, &bindings, &xml_ids); + settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, &bound_text, &mut restyled, &mut notes)?; if empty > 0 { notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); } @@ -551,6 +558,42 @@ fn bind_variables( notes.extend(ai::bindings_note(skipped)); } +/// The text object slots of `doc` that `bindings` binds to a text variable, each named by its XML id +/// (`ids`). +fn text_bound_slots(doc: &Document, bindings: &[ai::BindingRef], ids: &std::collections::HashMap>) -> BTreeSet { + bindings + .iter() + .filter(|b| doc.variables.variable(&b.variable).is_some_and(|v| v.kind == VariableKind::Text)) + .filter_map(|b| b.target.as_ref().and_then(|t| ids.get(t).copied().flatten())) + .collect() +} + +/// Warped type stays type where its live warp draws as its look, and where a text variable binds it +/// (`bound_text`, by slot). Elsewhere the look takes its place, and one note says how many did. +/// `filled` says what each slot became, and the comparisons with the page leave out the boxes in +/// `restyled`. Fails as [`resolve_text_wrap`] does when the looks give the wrap shapes too many +/// anchors. +fn settle_warped_type( + warped: ai::WarpedType, + doc: &mut Document, + filled: &std::collections::HashMap, + threaded: &BTreeSet, + bound_text: &BTreeSet, + restyled: &mut Vec, + notes: &mut Vec, +) -> Result<(), String> { + let made = |slot| match filled.get(&slot).map(|f| f.text.as_slice()).filter(|_| !bound_text.contains(&slot)) { + Some(&[one]) => Some(one), + _ => None, + }; + let replaced = warped.settle(doc, made, threaded, restyled); + if replaced > 0 { + notes.push(format!("{}: {replaced}", ai::WARPED_TYPE_DRAWN)); + resolve_text_wrap(doc)?; + } + Ok(()) +} + /// Most anchors copied into the wrap shapes of a file's area type ([`vectorcraft_doc::text::wrap_work`]). const MAX_WRAP_WORK: usize = 2_000_000; @@ -1356,18 +1399,47 @@ fn in_units(board: Option, page: &Document, unit: f64) -> bool { unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0) } -/// The most artboards [`shown_artboard`] weighs, a render each. +/// The most artboards [`shown_artboard`] weighs for the first page, and the most [`page_boards`] +/// weighs for all the pages after it together, a render each. const MAX_BOARDS_WEIGHED: usize = 16; +/// Does artboard `a` have the size of `page`? +fn board_fits(a: &vectorcraft_doc::Artboard, page: &Rect) -> bool { + (a.rect.width() - page.width()).abs() <= 0.5 && (a.rect.height() - page.height()).abs() <= 0.5 +} + +/// The last of the artboards `boards` that each page of `pages` can show when every page shows +/// one, in the pages' order. A page after the first shows an artboard of its size. Working from +/// the last page back, each page's entry is the last artboard of its size before the next page's +/// entry. The first page's entry is the artboard before the second page's entry, or the last +/// artboard when there is one page. `None` when the pages after the first have no such artboards. +fn last_boards(boards: &[vectorcraft_doc::Artboard], pages: &[Rect]) -> Option> { + let mut last = vec![0; pages.len()]; + let mut end = boards.len(); + for (i, page) in pages.iter().enumerate().skip(1).rev() { + let j = (1..end).rev().find(|&j| boards.get(j).is_some_and(|a| board_fits(a, page)))?; + *last.get_mut(i)? = j; + end = j; + } + if let Some(l) = last.first_mut() { + *l = end.checked_sub(1)?; + } + Some(last) +} + /// Which of the artboards of `doc` (the layers' document) the page `page_rect` of `visible` shows, /// when `visible` has fewer pages than `doc` has artboards (a PDF saved with a range of them): of -/// those of the page's size (`fits`), the one whose art draws most like the page, the first of -/// them on a tie. Else (and for an EPS, whose page is its art) the first artboard (0). +/// those of the page's size (`fits`) that leave an artboard for each later page ([`last_boards`]), +/// the one whose art draws most like the page, the first of them on a tie. Else (and for an EPS, +/// whose page is its art) the first artboard (0). fn shown_artboard(doc: &Document, visible: &Document, page: Page, page_rect: Rect, fits: impl Fn(&Rect) -> bool) -> usize { if matches!(page, Page::Art) || visible.artboards.len() >= doc.artboards.len() { return 0; } - let sized: Vec = doc.artboards.iter().enumerate().filter(|(_, a)| fits(&a.rect)).map(|(i, _)| i).take(MAX_BOARDS_WEIGHED).collect(); + let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); + let last = last_boards(&doc.artboards, &pages).and_then(|l| l.first().copied()).unwrap_or(usize::MAX); + let sized: Vec = + doc.artboards.iter().enumerate().filter(|(i, a)| *i <= last && fits(&a.rect)).map(|(i, _)| i).take(MAX_BOARDS_WEIGHED).collect(); if let [only] = sized.as_slice() { return *only; } @@ -1407,13 +1479,46 @@ fn pages_touched(bounds: Rect, pages: &[Rect], margin: f64) -> Vec { pages.iter().enumerate().filter(|(_, p)| !b.intersect(**p).is_zero_area()).map(|(i, _)| i).collect() } -/// The artboards of the layers' document (`boards`) that the pages show, one a page from the -/// first page's (`first`), when they are the pages' sizes: every artboard, or those of a PDF saved -/// with some of them (`first` after the first artboard). -fn page_boards<'a>(boards: &'a [vectorcraft_doc::Artboard], first: usize, pages: &[Rect]) -> Option<&'a [vectorcraft_doc::Artboard]> { - let same = |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; - let shown = boards.get(first..first.saturating_add(pages.len()))?; - ((first > 0 || pages.len() == boards.len()) && shown.iter().zip(pages).all(same)).then_some(shown) +/// The artboards of the layers' document (`doc`) that the pages of `visible` (`pages`) show, one a +/// page, the first page's being `first`, each of its page's size. The artboards are in the pages' +/// order. A page after the first shows an artboard after the previous page's, and only one that +/// leaves an artboard for each later page ([`last_boards`]). When several artboards can be the +/// page's (a PDF saved with some of them), the page shows the one whose art draws most like it, +/// the first of them on a tie. At most [`MAX_BOARDS_WEIGHED`] artboards are weighed for all the +/// later pages together, each page's candidates in order: a page takes the best of its candidates +/// that were weighed, or its first candidate when none of them were. `None` when the pages have no +/// such artboards. +fn page_boards(doc: &Document, visible: &Document, first: usize, pages: &[Rect]) -> Option> { + let boards = &doc.artboards; + let same = |j: usize, p: &Rect| boards.get(j).is_some_and(|a| board_fits(a, p)); + let last = last_boards(boards, pages)?; + if !pages.first().is_some_and(|p| same(first, p)) || last.first().is_none_or(|&l| first > l) { + return None; + } + let mut shown = vec![boards.get(first)?.clone()]; + let (mut after, mut weighed) = (first + 1, 0); + for (i, page) in pages.iter().enumerate().skip(1) { + let sized: Vec = (after..=*last.get(i)?).filter(|&j| same(j, page)).collect(); + let j = match sized.as_slice() { + [] => return None, + [only] => *only, + many => { + let weigh: Vec = many.iter().copied().take(MAX_BOARDS_WEIGHED.saturating_sub(weighed)).collect(); + weighed += weigh.len(); + let mut best: Option<(usize, f64)> = None; + for (j, a) in weigh.iter().filter_map(|&j| Some((j, boards.get(j)?))) { + let d = difference(visible, *page, doc, a.rect).any; + if best.is_none_or(|(_, b)| d < b) { + best = Some((j, d)); + } + } + best.map_or(many.first().copied(), |(j, _)| Some(j))? + } + }; + shown.push(boards.get(j)?.clone()); + after = j + 1; + } + Some(shown) } /// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard. @@ -1615,6 +1720,118 @@ mod candidate_tests { assert_eq!(more_pages(Some(&tiny_boards), &tiny, 1).len() + 1, MAX_PAGES_COMPARED); } + /// A page after the first doesn't take an artboard that a later page of another size needs, + /// even one that draws more like it. + #[test] + fn a_later_page_leaves_an_artboard_of_each_later_pages_size() { + let square = |doc: &mut Document, x: f64| { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), 0, art).unwrap(); + }; + let board = |doc: &Document, x0: f64, width: f64| vectorcraft_doc::Artboard { + rect: Rect::new(x0, 0.0, x0 + width, 100.0), + ..doc.artboards[0].clone() + }; + // Artboards of 100, 100, 200, 100 and 100 points, a square on the fourth. + let mut doc = Document::new(100.0, 100.0); + doc.artboards = [(0.0, 100.0), (150.0, 100.0), (300.0, 200.0), (550.0, 100.0), (700.0, 100.0)].map(|(x, w)| board(&doc, x, w)).to_vec(); + square(&mut doc, 560.0); + // Pages of 100, 100 and 200 points, the second with the fourth artboard's square. + let mut page = Document::new(100.0, 100.0); + let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(120.0, 0.0, 220.0, 100.0), Rect::new(240.0, 0.0, 440.0, 100.0)]; + page.artboards = pages.iter().map(|r| board(&page, r.x0, r.width())).collect(); + square(&mut page, 130.0); + let shown = page_boards(&doc, &page, 0, &pages).map(|b| b.iter().map(|a| a.rect.x0).collect::>()); + assert_eq!(shown, Some(vec![0.0, 150.0, 300.0])); + } + + /// The pages after the first weigh at most [`MAX_BOARDS_WEIGHED`] artboards together: a page + /// takes the best of its candidates that were weighed, and a page left with none to weigh + /// takes its first candidate, even when a later candidate draws like it. + #[test] + fn the_later_pages_weigh_a_bounded_number_of_artboards() { + let square = |doc: &mut Document, x: f64| { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), 0, art).unwrap(); + }; + let board = |doc: &Document, x0: f64, width: f64| vectorcraft_doc::Artboard { + rect: Rect::new(x0, 0.0, x0 + width, 100.0), + ..doc.artboards[0].clone() + }; + // Artboards 0 to 17 are 100 points wide, 150 apart; artboards 18 and 19 are 200 points + // wide. The second page's square is on artboard 17 and the third page's on artboard 19. + let n = MAX_BOARDS_WEIGHED + 2; + let mut doc = Document::new(100.0, 100.0); + let mut boards: Vec = (0..n).map(|i| board(&doc, i as f64 * 150.0, 100.0)).collect(); + let wide = n as f64 * 150.0; + boards.push(board(&doc, wide, 200.0)); + boards.push(board(&doc, wide + 250.0, 200.0)); + doc.artboards = boards; + square(&mut doc, (n - 1) as f64 * 150.0 + 10.0); + square(&mut doc, wide + 260.0); + let mut page = Document::new(100.0, 100.0); + let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(120.0, 0.0, 220.0, 100.0), Rect::new(240.0, 0.0, 440.0, 100.0)]; + page.artboards = pages.iter().map(|r| board(&page, r.x0, r.width())).collect(); + square(&mut page, 130.0); + square(&mut page, 250.0); + // The second page weighs artboards 1 to 16, which draw alike, and takes artboard 1. The + // third page has no weighing left and takes artboard 18. + let shown = page_boards(&doc, &page, 0, &pages).map(|b| b.iter().map(|a| a.rect.x0).collect::>()); + assert_eq!(shown, Some(vec![0.0, 150.0, wide])); + } + + /// The first page of a PDF of some artboards shows an artboard that leaves one for each later + /// page, even when a later artboard draws more like it. + #[test] + fn the_first_page_leaves_an_artboard_for_each_later_page() { + let square = |doc: &mut Document, x: f64| { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), 0, art).unwrap(); + }; + let board = |doc: &Document, x0: f64| vectorcraft_doc::Artboard { rect: Rect::new(x0, 0.0, x0 + 100.0, 100.0), ..doc.artboards[0].clone() }; + // Three artboards of one size, a square on the third. + let mut doc = Document::new(100.0, 100.0); + doc.artboards = [0.0, 150.0, 300.0].map(|x| board(&doc, x)).to_vec(); + square(&mut doc, 310.0); + // Two pages, the square on the first. + let mut page = Document::new(100.0, 100.0); + let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(120.0, 0.0, 220.0, 100.0)]; + page.artboards = pages.iter().map(|r| board(&page, r.x0)).collect(); + square(&mut page, 10.0); + let fits = |r: &Rect| (r.width() - 100.0).abs() <= 0.5 && (r.height() - 100.0).abs() <= 0.5; + let first = shown_artboard(&doc, &page, Page::Artboard, pages[0], fits); + assert_eq!(first, 0); + let shown = page_boards(&doc, &page, first, &pages).map(|b| b.iter().map(|a| a.rect.x0).collect::>()); + assert_eq!(shown, Some(vec![0.0, 150.0])); + } + /// A linked image showing a placeholder (`XF`) waits on the page, whose PDF part draws its /// pixels: the layers aren't used without it. #[test] @@ -1703,6 +1920,34 @@ mod candidate_tests { } } + /// Warped type whose look takes its place, with the type's wrap setting, is held to the same + /// limit on wrap shapes: a file whose looks would copy more than `MAX_WRAP_WORK` anchors isn't + /// read from its layers. + #[test] + fn warped_type_replaced_by_its_look_is_held_to_the_wrap_limit() { + for (texts, ok) in [(150, true), (250, false)] { + let mut doc = Document::new(100.0, 100.0); + (0..texts).for_each(|_| _ = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0))); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "TILT", Default::default()); + let mut text = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + let mut appearance = vectorcraft_doc::Appearance::default(); + appearance.effects.push(vectorcraft_doc::Effect { id: "warp.arc".into(), params: serde_json::json!({"bend": 40.0}), visible: true }); + text.appearance = appearance.into(); + text.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default()); + let top = doc.layers[0].children().map_or(0, Vec::len); + let id = doc.insert(Some(doc.layers[0].id), top, text).unwrap(); + let look = insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 10_000); + let look = doc.remove(look).unwrap(); + assert!(resolve_text_wrap(&mut doc).is_ok()); + let warped = ai::WarpedType::unchecked(vec![(id, (*look).clone())]); + let filled = std::collections::HashMap::from([(id, Filled { text: vec![id], objects: vec![id] })]); + let mut notes = vec![]; + let settled = settle_warped_type(warped, &mut doc, &filled, &BTreeSet::new(), &BTreeSet::new(), &mut vec![], &mut notes); + assert_eq!(settled.is_ok(), ok, "{texts} text objects"); + assert_eq!(notes, ["warped text objects that came in as drawn art, not as editable type: 1"]); + } + } + /// Area type that wrapped around an object taken out of the layers flows without it. #[test] fn wrap_shapes_go_with_their_wrap_object() { diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 22c3cf7c7..0575eebcc 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -223,6 +223,15 @@ impl AiLayers { } } +/// The id of the artboard of `doc` that the editing copy `private` (as [`layered_ai`] takes it) +/// names as its active one (`CropAreaActive`, an index into its `ArtboardArray`), for `doc` +/// restored from the document a `.ai` file carries beside that copy: `None` when the copy names +/// the first artboard, names none, can't be decoded, or names an artboard `doc` doesn't have. +pub fn ai_opened_artboard(private: &[u8], doc: &Document) -> Option { + let data = ai::decode_private(private).ok()?; + active_page(ai::active_artboard(&data), doc) +} + /// `doc` (a file read as its page or PDF part, in place of its layers) with the variables, data /// sets, active data set and lock of the variable library of its editing data `data`, as [`build`] /// gives a document read from the layers, and the note about what of the library wasn't read diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index bac7108ac..3cfc3a6b5 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -74,6 +74,7 @@ pub(super) fn make_slots( // Area type and type on a path in the object's own frame: the text document's frame // of a copied object keeps its original's path. frame_text_from_art(info, &mut node, &mut region); + story.flip_on_path(&mut node, info.frame); if let NodeKind::Text(text) = &mut node.kind && story.fit_first_line(text) { diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index baff41dd8..f0cda3088 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -29,6 +29,7 @@ pub fn is_loss(note: &str) -> bool { note.ends_with(TEXT_LEFT_OUT) || note.ends_with(TYPE_LEFT_OUT) || note.ends_with("they came in as the page's art") + || note.starts_with(ai::WARPED_TYPE_DRAWN) || note == PAGE_TYPE_LEFT_OUT || note == PAGE_TYPE_UNCHECKED || [ART_LEFT_OUT, LAYERS_UNREAD, DREW_DIFFERENTLY, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index dde26bc8d..a2d6ec2af 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -177,11 +177,12 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() { let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n") + "\n" + clip_fill; let r = import(&eps(&page, &art)).unwrap(); let outer = &r.document.layers[0]; - assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[3]", "group[1]", "other"]); + assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]); let children = outer.children().unwrap(); assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out"); - // The clip path comes first in its group. - assert!(matches!(children[2].children().unwrap()[0].kind, NodeKind::Path { clipping: true, .. })); + // The clip path comes first in its group, with its fill. + let clip = &children[2].children().unwrap()[0]; + assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. }) && clip.clip_paint().fill.is_some(), "{clip:?}"); // The sublayer is a layer of the layer. assert!(matches!(&children[4].kind, NodeKind::Layer { .. })); assert_eq!(children[4].name.as_deref(), Some("Inner")); @@ -1960,6 +1961,94 @@ fn the_later_pages_of_a_pdf_of_some_artboards_are_not_compared() { assert_eq!(d.artboards.len(), 4, "{w:?}"); } +/// The editing data of four 100-point artboards side by side, 50 apart (art space, y up), with a +/// 4-point square on each at (`squares[i]`, 10 + 20 × i), and a text object with no story in the +/// file. +fn four_boards_with_type(squares: [u32; 4]) -> String { + let board = |x0: u32| { + format!( + "%_/Dictionary :\n%_{x0} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + x0 + 100 + ) + }; + let boards = (0..4u32).map(|i| board(i * 150)).collect::(); + let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{boards}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"); + let art: Vec = (0..4u32).map(|i| square(i * 150 + squares[i as usize], 10 + 20 * i)).collect(); + native(&(boards + &layer("Art", true, &(art.join("\n") + "\n" + &text_object(0))))) +} + +/// The PDF part of a file with [`four_boards_with_type`] saved with two of its artboards: a page +/// for each in a row, page `i` with a square `squares[i].0` from its left and `squares[i].1` up +/// from its bottom, and "Hi" 30 from the second page's left and 20 down. +fn two_pages_with_type(squares: [(f64, u32); 2]) -> Document { + let at = |(x, y): (f64, u32), page: f64| pdf_part(page + x, 100.0 - f64::from(y) - 4.0); + let mut pdf = at(squares[0], 0.0); + let mut second = pdf.artboards[0].clone(); + (second.id, second.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + pdf.artboards.push(second); + let other = at(squares[1], 120.0); + let square = pdf.reid(&other.layers[0].children().unwrap()[0]); + let layer_id = pdf.layers[0].id; + pdf.insert(Some(layer_id), 1, square).unwrap(); + let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(150.0, 20.0), "Hi", style); + let id = pdf.alloc_id(); + pdf.insert(Some(layer_id), 2, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap(); + pdf +} + +/// A PDF saved with artboards that don't follow one another has a page for each: the type on a +/// later page goes to the artboard that page shows, not to the one after the previous page's. +#[test] +fn type_on_a_later_page_of_a_pdf_of_some_artboards_goes_to_its_artboard() { + // The squares 10, 20, 30 and 40 from each artboard's left. + let art = four_boards_with_type([10, 20, 30, 40]); + // Pages for the second and fourth artboards. The fourth artboard starts at x 450, so the type + // 30 from its left is at x 480. + let (d, w, read) = crate::layered_ai(&private(&art), two_pages_with_type([(20.0, 30), (40.0, 70)]), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(d.artboards.len(), 4, "{w:?}"); + let texts = text_of(&d.layers[0]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); + let [.., e, f] = texts[0].xf.as_coeffs(); + assert!((e - 480.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the fourth artboard: {:?}", texts[0].xf); +} + +/// A PDF saved with the first and third of four artboards: the type on its second page goes to +/// the third artboard. +#[test] +fn type_on_the_second_page_of_a_pdf_of_the_first_and_third_artboards_goes_to_the_third() { + let art = four_boards_with_type([10, 20, 30, 40]); + // The third artboard starts at x 300, so the type 30 from its left is at x 330. + let (d, w, read) = crate::layered_ai(&private(&art), two_pages_with_type([(10.0, 10), (30.0, 50)]), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(d.artboards.len(), 4, "{w:?}"); + let texts = text_of(&d.layers[0]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); + let [.., e, f] = texts[0].xf.as_coeffs(); + assert!((e - 330.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the third artboard: {:?}", texts[0].xf); +} + +/// An EPS whose page is its art's box: an object the layers can't draw takes the page's art from +/// that box, when the editing data's first artboard has the page's size elsewhere. +#[test] +fn an_unread_object_of_an_eps_of_its_art_box_takes_the_page_art_on_that_box() { + let point = |x: u32, y: u32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n"); + let board = |x0: u32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(x0, 100, 1), point(x0 + 100, 0, 2)); + let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board(200), board(0)); + let boards = format!("%_/Document :\n%_/Dictionary :\n{array}%_; /NotRecorded ,\n%_;\n"); + let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0)); + let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;"; + let layers = layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{instance}\n{}", square(70, 10))); + let art = native(&format!("{boards}{symbol}{layers}")); + let cyan = page_square(50, 10).replace("0 0 1 0 setcmykcolor", "1 0 0 0 setcmykcolor"); + let page = format!("{}\n{cyan}\n{}", page_square(10, 10), page_square(70, 10)); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Live", "Mixed"], "{:?}", r.warnings); + assert_eq!(kinds(&r.document.layers[1]), ["group[1]", "path"], "{:?}", r.warnings); + replacement_is_cyan(&r.document, 1, 0); +} + /// Layers that draw each page's art on its artboard come in. #[test] fn layers_that_match_every_page_come_in() { @@ -3857,6 +3946,37 @@ fn area_type_and_type_on_a_path_come_from_the_text_document() { assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); } +/// Type on a path keeps the effect, alignment and flip its frame keeps. The flip applies to the +/// object's own path, the one its art draws, once. +#[test] +fn type_on_a_path_keeps_its_type_on_a_path_options() { + let (a, b, c) = (canvas(10.0, 70.0), canvas(30.0, 70.0), canvas(50.0, 70.0)); + let segments = + format!("{} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}", a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1, b.0, b.1, b.0, b.1, c.0, c.1, c.0, c.1); + let frame = |options: &str| format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {segments} ] >> /2 << /0 2 /6 [ 0.5 2.0 ] {options} >> >> >>"); + let frames = [frame("/11 << /0 true /1 1 /2 0 /4 -28 /18 -28.0 /6 false /22 0.025 >>"), frame("")]; + let stories = [story("Flipped\\r", &[0], "/0 0", "/1 8.0"), story("Plain\\r", &[1], "/0 0", "/1 8.0")]; + let doc = document_of(&frames, &stories); + let objects = text_object(0) + &text_object(1); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &objects)), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 2, "{:?}", r.warnings); + let (flipped, plain) = (texts[0], texts[1]); + assert_eq!((flipped.path_effect, flipped.path_align), (vectorcraft_doc::PathEffect::Skew, vectorcraft_doc::PathAlign::Ascender)); + assert_eq!((plain.path_effect, plain.path_align), (vectorcraft_doc::PathEffect::Rainbow, vectorcraft_doc::PathAlign::Baseline)); + let first_x = |t: &vectorcraft_doc::TextObject| { + let vectorcraft_doc::TextKind::OnPath { path, .. } = &t.kind else { panic!("type on a path: {:?}", t.kind) }; + let Some(kurbo::PathEl::MoveTo(p)) = path.to_bezpath().elements().first().copied() else { panic!("a path") }; + p.x + }; + assert_eq!(first_x(plain), 10.0); + assert_eq!(first_x(flipped), 50.0); + // The file's range, 0.25 to the end of the path, stays that stretch of it. + let (from, to) = flipped.kind.path_span().unwrap(); + assert!(from.abs() < 1e-3 && (to - 0.75).abs() < 1e-3, "{from} {to}"); +} + /// Area type keeps the rows, columns, gutters and first baseline its frame keeps, and so does /// each frame of a thread; a frame without them keeps the defaults. #[test] @@ -4139,6 +4259,11 @@ fn a_thai_story_stays_one_text_object() { /// A story of `text` on frame 0 whose layout sets glyphs `advance` wide round the outside of a /// circle of radius `radius`, the circle's length between their centres `fraction` of an advance. fn round_story(text: &str, advance: f64, radius: f64, fraction: f64) -> String { + round_story_with(text, advance, radius, fraction, "") +} + +/// The same with `extra` in the line's `G`. +fn round_story_with(text: &str, advance: f64, radius: f64, fraction: f64, extra: &str) -> String { let n = text.chars().count(); let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![]); for i in 0..=n { @@ -4154,7 +4279,7 @@ fn round_story(text: &str, advance: f64, radius: f64, fraction: f64) -> String { format!( "<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \ /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 12.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> ] \ - /2 [ << /99 /PC /6 [ << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >> ] >> ] >> >>", + /2 [ << /99 /PC /6 [ << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G {extra} /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >> ] >> ] >> >>", nums(&xs), nums(&ys), matrices.join(" "), @@ -4214,6 +4339,57 @@ fn type_round_a_circle_is_spaced_as_the_file_lays_it_out() { } } +/// Type aligned to its path by its ascender: the file's baselines run on a smaller circle than the +/// path, and the type comes in spaced as it runs along the path, not along those baselines. +#[test] +fn aligned_type_round_a_circle_runs_along_its_path_as_the_file_lays_it_out() { + let k = 40.0 * 0.552_284_75; + let points = [ + (90.0, 50.0), + (90.0, 50.0 - k), + (50.0 + k, 10.0), + (50.0, 10.0), + (50.0 - k, 10.0), + (10.0, 50.0 - k), + (10.0, 50.0), + (10.0, 50.0 + k), + (50.0 - k, 90.0), + (50.0, 90.0), + (50.0 + k, 90.0), + (90.0, 50.0 + k), + (90.0, 50.0), + ]; + let all: Vec<(f64, f64)> = points.iter().map(|(x, y)| canvas(*x, *y)).collect(); + let segment = |i: usize| (0..4).map(|j| format!("{} {}", all[i * 3 + j].0, all[i * 3 + j].1)).collect::>().join(" "); + let frame = format!( + "<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 2 /6 [ 2.0 5.99 ] /11 << /2 0 >> >> >> >>", + (0..4).map(segment).collect::>().join(" ") + ); + // Baselines 10 inside the circle of radius 40. Along the path the centres of glyphs 5 wide are + // 5 × 40/48 apart (spacing 8), so on the baselines they are 5 × 30/48 apart. + let doc = document_of(&[frame], &[round_story_with("abcdefgh", 5.0, 30.0, 30.0 / 48.0, "/0 << /0 [ 0 10 ] >>")]); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + assert_eq!(t.path_align, vectorcraft_doc::PathAlign::Ascender); + assert!((t.path_spacing - 8.0).abs() < 0.05, "{}", t.path_spacing); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let centres: Vec<(f64, f64)> = layout + .glyphs + .iter() + .map(|g| (g.origin.x + g.angle.cos() * g.advance * 0.5 - 50.0, g.origin.y + g.angle.sin() * g.advance * 0.5 - 50.0, g.advance)) + .map(|(x, y, a)| (y.atan2(x), a)) + .collect(); + assert_eq!(centres.len(), 8); + for w in centres.windows(2) { + let turn = (w[1].0 - w[0].0 + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + let ratio = turn.abs() * 40.0 / ((w[0].1 + w[1].1) * 0.5); + assert!((ratio - 40.0 / 48.0).abs() < 0.01, "{ratio}"); + } +} + #[test] fn threaded_area_type_flows_through_its_frames() { let frames = [ @@ -4430,6 +4606,26 @@ fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() { assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}"); } +/// A copy of flipped type on a path takes its own `/Art` path, and that path is the one that flips. +#[test] +fn a_copied_flipped_type_on_a_path_flips_its_own_art_path() { + let (a, b) = (canvas(10.0, 20.0), canvas(60.0, 20.0)); + let path = format!( + "<< /0 << /0 [ 0 0 ] /1 << /0 [ {} {} {} {} {} {} {} {} ] >> /2 << /0 2 /6 [ 0.0 1.0 ] /11 << /0 true >> >> >> >>", + a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1 + ); + let doc = document_of(&[path], &[story("Copied\r", &[0], "/0 0", "/1 8.0")]); + let copy = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n30 50 m\n80 50 L\nN\n;\n/ConfiningPath ,\n2 /TextAntialiasing ,\n;\n"; + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, copy)), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let vectorcraft_doc::TextKind::OnPath { path, .. } = &texts[0].kind else { panic!("type on a path: {:?}", texts[0].kind) }; + // The copy's path runs from x 30 to x 80 at art y 50, so flipped it starts at x 80, 50 down the page. + let Some(kurbo::PathEl::MoveTo(first)) = path.to_bezpath().elements().first().copied() else { panic!("a path") }; + assert_eq!((first.x, first.y), (80.0, 50.0)); +} + /// A raster print page can carry a fully vector editing copy. The legacy mesh has no /// opacity operand and its vector mask hides half of it: ignoring either loses this import. #[test] @@ -4598,6 +4794,334 @@ fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() { check(&r.document, &r.warnings); } +/// Where [`warped_style`] puts its Warp. +#[derive(Clone, Copy)] +enum WarpOn { + Object, + Fill, + Stroke, +} + +/// An art style section with style `Warped`: a red fill with Effect › Warp › Arc at `bend` percent on +/// `on`. +fn warped_style(bend: f64, on: WarpOn) -> String { + let basic = |name: &str, body: &str| { + format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") + }; + let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 1 1 0 1 0 0 Xa\n0 1 0 0 0 Xy\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"); + let warp = basic( + "Adobe Deform", + &format!( + "1 /Int (DeformStyle) ,\n{} /Real (DeformValue) ,\n0 /Real (DeformHoriz) ,\n0 /Real (DeformVert) ,\n0 /Bool (Rotate) ,\n", + bend / 100.0 + ), + ); + let stroke = || { + basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 2 w 10 M []0 d0 XR\n0 0 0 1 0 0 0 XA\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n") + }; + let conduit = || basic("Conduit Filter", ""); + let chain = match on { + WarpOn::Object => compound("Chain Style Filter", &[fill, warp]), + WarpOn::Fill => { + compound("Chain Style Filter", &[compound("Stack Style Filter", &[compound("Chain Style Filter", &[warp, fill]), conduit()])]) + } + WarpOn::Stroke => { + compound("Chain Style Filter", &[compound("Stack Style Filter", &[compound("Chain Style Filter", &[warp, stroke()]), fill, conduit()])]) + } + }; + format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Warped) /Name ,\n/ActiveStyle :\n{chain}%AI9_EndArtStyles\n") +} + +/// `outline`, a path in the document's coordinates, as filled paths in a 100 × 100 file's art space, +/// in the red of [`warped_style`]. +fn drawn_as_operators(outline: &kurbo::BezPath) -> String { + use kurbo::PathEl; + let mut out = String::from("0 1 1 0 1 0 0 Xa\n"); + let y = |v: f64| 100.0 - v; + let mut last = kurbo::Point::ZERO; + for el in outline.elements() { + match *el { + PathEl::MoveTo(p) => out += &format!("{} {} m\n", p.x, y(p.y)), + PathEl::LineTo(p) => out += &format!("{} {} L\n", p.x, y(p.y)), + PathEl::QuadTo(c, p) => { + let (a, b) = (last + (c - last) * (2.0 / 3.0), p + (c - p) * (2.0 / 3.0)); + out += &format!("{} {} {} {} {} {} C\n", a.x, y(a.y), b.x, y(b.y), p.x, y(p.y)); + } + PathEl::CurveTo(a, b, p) => out += &format!("{} {} {} {} {} {} C\n", a.x, y(a.y), b.x, y(b.y), p.x, y(p.y)), + PathEl::ClosePath => out += "f\n", + } + last = match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => p, + PathEl::ClosePath => last, + }; + } + out +} + +/// What `n` holds as art, its paths joined, in the document's coordinates. +fn outlines_of(n: &Node) -> kurbo::BezPath { + let mut out = kurbo::BezPath::new(); + if let NodeKind::Path { path, .. } = &n.kind { + out.extend(path.to_bezpath()); + } + for c in n.children().into_iter().flatten() { + out.extend(outlines_of(c)); + } + out +} + +/// The editing copy of `objects` text objects of `text` under style `style`, each with the look +/// `drawing`. +fn warped_file(style: &str, text: &str, objects: u32, drawing: &str) -> String { + let stories: Vec<_> = (0..objects).map(|_| (text, 0u8, (8200.0, 8180.0), (0.0, 0.0), 0.0)).collect(); + let doc = text_document(&stories); + let art: String = (0..objects) + .map(|n| { + let look = format!("u\n{drawing}\nU\n9 () XW\n"); + let own: String = text_object(n).lines().map(|l| format!("%_{l}\n")).collect(); + format!("{look}{own}1 (Warped) XW\n") + }) + .collect(); + native_with_text(&(style.to_string() + &layer("Art", true, &art)), &doc) +} + +/// The type of `doc`, and whether it has a warp. +fn warped_texts(doc: &Document) -> Vec<(String, bool)> { + let mut out = vec![]; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + out.push(( + t.plain_text(), + n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).any(|e| e.id.starts_with("warp.")), + )); + } + }); + out +} + +#[test] +fn warped_type_that_draws_as_its_look_stays_live_type() { + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, &plain_warped_look()); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d), [("TILT".to_string(), true)], "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); +} + +/// What the live Arc at 40% draws of the type `TILT` that [`warped_file`] places, as look operators. +fn plain_warped_look() -> String { + let doc = text_document(&[("TILT\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let plain = layer("Art", true, &text_object(0)); + let (d, notes) = crate::ai_alone(&private(&native_with_text(&plain, &doc))).unwrap(); + let mut text = None; + d.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + text = Some(n.clone()); + } + }); + let mut text = text.unwrap_or_else(|| panic!("no type: {notes:?}")); + text.appearance = warped_appearance().into(); + drawn_as_operators(&outlines_of(&vectorcraft_effects::expand_outlined(None, &text, None).unwrap())) +} + +/// The appearance that [`warped_style`] at 40% on the object reads as. +fn warped_appearance() -> vectorcraft_doc::Appearance { + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, ""); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + let mut out = None; + d.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + out = Some((*n.appearance).clone()); + } + }); + out.unwrap_or_else(|| panic!("no type: {notes:?}")) +} + +#[test] +fn warped_type_that_draws_apart_from_its_look_comes_in_as_the_look() { + // GUARDS: a warp on type opened live with no check, so a live warp that draws another shape from + // the file's moved and resized the type. + let drawing = "0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + for on in [WarpOn::Object, WarpOn::Fill, WarpOn::Stroke] { + let art = warped_file(&warped_style(40.0, on), "TILT\r", 2, drawing); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d), [], "{notes:?}"); + let looks = d.layers[0].children().unwrap(); + assert_eq!(looks.len(), 2, "{looks:#?}"); + assert!(looks.iter().all(|l| matches!(l.kind, NodeKind::Group { .. }) && l.visual_bounds().is_some()), "{looks:#?}"); + // One note, with how many, that counts as a loss. + let noted: Vec<&String> = notes.iter().filter(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)).collect(); + assert_eq!(noted, ["warped text objects that came in as drawn art, not as editable type: 2"], "{notes:?}"); + assert!(crate::is_loss(noted[0])); + } +} + +/// Warped type that a text variable binds stays type with the effect live and keeps its binding, +/// though its live warp draws apart from its look. +#[test] +fn warped_type_bound_to_a_text_variable_stays_bound_type() { + use vectorcraft_testkit::ai::{XmlValue, named, variable_library_data, xml_element, xml_node}; + let attributes = + [("trait", XmlValue::Str("textcontent")), ("varName", XmlValue::Unicode("v_text")), ("docRef", XmlValue::Raw("/XMLUIDREF : (type_x5F_1) ;"))]; + let declared = [xml_node(1, "variable", XmlValue::Str(""), &attributes, &[])]; + let set = xml_element("variableSet", &[("locked", "none")], &[xml_element("variables", &[], &declared)]); + let library = variable_library_data(&xml_element("variableSets", &[], &[set])); + let drawing = "0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); + let art = format!("u\n{drawing}\nU\n9 () XW\n{own}{}1 (Warped) XW\n", named("type_x5F_1")); + let doc = text_document(&[("TILT\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let file = native_with_text(&(library + &warped_style(40.0, WarpOn::Object) + &layer("Art", true, &art)), &doc); + let (d, notes) = crate::ai_alone(&private(&file)).unwrap(); + assert_eq!(warped_texts(&d), [("TILT".to_string(), true)], "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); + let mut text = None; + d.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + text = Some(n.id); + } + }); + assert_eq!(d.variables.objects_of("v_text"), [text.unwrap()], "{notes:?}"); +} + +/// `operators` as the page's content: a red fill of the same paths. +fn page_of(operators: &str) -> String { + let mut page = String::from("1 0 0 setrgbcolor\n"); + for line in operators.lines() { + let (args, op) = line.rsplit_once(' ').unwrap_or(("", line)); + let op = match op { + "m" => "moveto", + "L" => "lineto", + "C" => "curveto", + "f" => "closepath fill", + _ => continue, + }; + page += &format!("{args} {op}\n"); + } + page +} + +/// Warped type that opens as its look is compared with the page again, as other art is. +#[test] +fn warped_type_that_comes_in_as_its_look_is_held_to_the_page() { + let drawing = "0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, drawing); + let same = import(&eps(&page_of(drawing), &art)).unwrap(); + assert_eq!(warped_texts(&same.document), [], "{:?}", same.warnings); + assert!(same.warnings.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{:?}", same.warnings); + assert!(!same.warnings.iter().any(|w| w.contains("drew differently")), "{:?}", same.warnings); + // A page that paints only part of the look's box: the box is compared, not left out. + let other = page_of("60 60 m\n90 60 L\n90 75 L\n60 75 L\nf"); + let differs = import(&eps(&other, &art)).map(|r| r.warnings).unwrap_or_else(|why| vec![why.to_string()]); + assert!(differs.iter().any(|w| w.contains("differs from the page") || w.contains("drew differently")), "{differs:?}"); +} + +/// Warped type spends the page's share of the budget one object after another: with enough of them +/// the later ones open as their looks, though each draws close enough on its own. +#[test] +fn warped_type_shares_the_page_budget() { + let look = plain_warped_look(); + let style = warped_style(40.0, WarpOn::Object); + let art = warped_file(&style, "TILT\r", 12, &look); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d).len(), 12, "{notes:?}"); + // The look draws a small square more than the live warp does. + let art = warped_file(&style, "TILT\r", 12, &format!("{look}0 1 1 0 1 0 0 Xa\n2 2 m\n3.5 2 L\n3.5 3.5 L\n2 3.5 L\nf")); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + let live = warped_texts(&d).len(); + assert!(live > 0 && live < 12, "{live}: {notes:?}"); + assert!(notes.iter().any(|w| w == &format!("{}: {}", crate::import::ai::WARPED_TYPE_DRAWN, 12 - live)), "{live}: {notes:?}"); +} + +/// Envelopes and warped type share one count of checks: past it, warped type that would pass opens +/// as its look. +#[test] +fn warped_type_past_the_envelopes_checks_comes_in_as_its_look() { + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + let rect = "20 5 m\n20 95 L\n80 95 L\n80 5 L\n20 5 L\n"; + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + let mesh = format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 5.0, 20.0, 35.0, 40.0, 5.0), + corner(20.0, 95.0, 40.0, 95.0, 20.0, 65.0), + corner(80.0, 95.0, 80.0, 65.0, 60.0, 95.0), + corner(80.0, 5.0, 60.0, 5.0, 80.0, 35.0), + ); + let red = "0 1 1 0 1 0 0 Xa\n"; + let envelope = format!( + "{}%_/ArtDictionary :\n%_131073 /Int (AIDeformOptionsKey) ,\n%_50 /Int (AIDeformFidelityKey) ,\n%_0 /Bool (AIDeformEditModeKey) ,\n%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n%_\nu\n{red}{rect}f\nU\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{mesh}U\n")), + hidden(&format!("u\n{red}{rect}f\nU\n")), + ); + let look = format!("u\n{}\nU\n9 () XW\n", plain_warped_look()); + let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); + let doc = text_document(&[("TILT\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let file = |envelopes: usize| { + let body = format!("{}{look}{own}1 (Warped) XW\n", envelope.repeat(envelopes)); + native_with_text(&(warped_style(40.0, WarpOn::Object) + &layer("Art", true, &body)), &doc) + }; + let (d, notes) = crate::ai_alone(&private(&file(1))).unwrap(); + assert_eq!(warped_texts(&d).len(), 1, "{notes:?}"); + let (d, notes) = crate::ai_alone(&private(&file(crate::import::ai::MAX_CHECKED))).unwrap(); + assert_eq!(warped_texts(&d), [], "{notes:?}"); + assert!(notes.iter().any(|w| w == &format!("{}: 1", crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); +} + +#[test] +fn warped_type_with_a_look_that_draws_nothing_stays_type() { + // The look only held the type, which the slot's text object replaces, or a path that paints + // nothing: there is no art to stand in. + for drawing in ["", LOOK_TYPE, "54 56 m\n100 56 L\n100 80 L\n54 80 L\nn"] { + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, drawing); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d), [("TILT".to_string(), true)], "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); + } +} + +/// A look that draws type as well as art is missing the type once the slot's text object takes it, +/// so it can't stand in: the object stays type, and its letters stay on the page. +#[test] +fn warped_type_whose_look_also_draws_type_stays_type() { + let drawing = format!("{LOOK_TYPE}0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"); + let art = warped_file(&warped_style(40.0, WarpOn::Fill), "TILT\r", 1, &drawing); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d), [("TILT".to_string(), true)], "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); +} + +/// A look with an object that wasn't read makes the file open from its page: the look isn't used. +#[test] +fn warped_type_whose_look_holds_an_unread_object_opens_from_the_page() { + let drawing = "0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf\nfrobnicate"; + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, drawing); + let page = page_of("54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(warped_texts(&r.document), [], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("weren't read")), "{:?}", r.warnings); +} + +/// Warped frames of threaded type stay type, and stay one thread, whatever their looks draw. +#[test] +fn warped_threaded_type_stays_threaded() { + let frames = [ + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(10.0, 60.0, 40.0, 80.0)), + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 60.0, 80.0, 80.0)), + ]; + let doc = document_of(&frames, &[story("A story in two frames\\r", &[0, 1], "/0 0", "/1 10.0")]); + let body: String = (0..2) + .map(|frame| { + let own: String = frame_object(0, frame).lines().map(|l| format!("%_{l}\n")).collect(); + format!("u\n0 1 1 0 1 0 0 Xa\n2 2 m\n9 2 L\n9 9 L\n2 9 L\nf\nU\n9 () XW\n{own}1 (Warped) XW\n") + }) + .collect(); + let art = native_with_text(&(warped_style(40.0, WarpOn::Object) + &layer("Art", true, &body)), &doc); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(d.text_threads.len(), 1, "{notes:?}"); + assert_eq!(warped_texts(&d).len(), 2, "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); +} + /// An art style section with style `Add`: Effect › Pathfinder › Add in the object's chain, over a /// fill and stroke that paint nothing. fn pathfinder_add_style() -> String { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index c5ee02e72..bf515de66 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -44,8 +44,8 @@ mod tiff; pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data}; pub use import::{ - AiLayers, Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_layers_alone, family_style, import, import_trusting, import_with, is_loss, - layered_ai, layered_ai_from, page, + AiLayers, Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, family_style, import, import_trusting, + import_with, is_loss, layered_ai, layered_ai_from, page, }; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask}; pub use print::{PrintJob, PrintPage, print}; diff --git a/crates/eps/tests/envelope_ink.rs b/crates/eps/tests/envelope_ink.rs new file mode 100644 index 000000000..eccbf8a8f --- /dev/null +++ b/crates/eps/tests/envelope_ink.rs @@ -0,0 +1,30 @@ +//! Reading an `.ai` file makes envelopes measure type by its glyph outlines, as the app does. One +//! test in its own process, so nothing else has registered the measure before the file is read. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Point, Rect}; + +/// The largest distance between the edges of `a` and `b`. +fn off(a: Rect, b: Rect) -> f64 { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())) +} + +#[test] +fn reading_an_ai_file_measures_type_in_envelopes_by_its_glyphs() { + let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() }); + let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())))); + let laid = n.geometric_bounds().unwrap(); + let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()); + assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}"); + let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap(); + assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}"); + + let file = "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 3.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"; + vectorcraft_eps::import(file.as_bytes()).unwrap(); + let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap(); + assert!(off(after, ink) < 1e-6, "after reading, type is measured by its glyphs: {after:?} vs {ink:?}"); +} diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml new file mode 100644 index 000000000..0108b50d9 --- /dev/null +++ b/crates/js/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "vectorcraft-js" +description = "JavaScript for scripts: an ECMAScript 5.1 lexer and parser to a syntax tree with source positions" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +unicode-general-category = "1" + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs new file mode 100644 index 000000000..f0dd6b44b --- /dev/null +++ b/crates/js/src/ast.rs @@ -0,0 +1,334 @@ +//! The syntax tree: expressions, statements and functions kept in arenas ([`Ast`]) and referred to +//! by index, each with its source span. Nodes hold indices rather than boxes, so dropping a deeply +//! nested tree does not recurse. + +use crate::token::Span; + +/// An expression's index in [`Ast::exprs`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ExprId(pub u32); + +/// A statement's index in [`Ast::stmts`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StmtId(pub u32); + +/// A function's index in [`Ast::functions`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FunctionId(pub u32); + +/// An identifier as written, with its span. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Name { + pub name: String, + pub span: Span, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnaryOp { + Delete, + Void, + Typeof, + Plus, + Minus, + BitNot, + Not, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UpdateOp { + Increment, + Decrement, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BinaryOp { + Mul, + Div, + Mod, + Add, + Sub, + Shl, + Shr, + UShr, + Lt, + Gt, + LtEq, + GtEq, + Instanceof, + In, + Eq, + NotEq, + StrictEq, + StrictNotEq, + BitAnd, + BitXor, + BitOr, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogicalOp { + And, + Or, +} + +/// `=` (`None`) or a compound assignment's operator (`+=` is `Some(BinaryOp::Add)`). +pub type AssignOp = Option; + +/// A property name in an object literal. +#[derive(Clone, Debug, PartialEq)] +pub enum PropertyKey { + Ident(String), + String(Vec), + Number(f64), +} + +impl PropertyKey { + /// The key as the string a property is stored under (a number through ToString). + pub fn to_utf16(&self) -> Vec { + match self { + PropertyKey::Ident(name) => name.encode_utf16().collect(), + PropertyKey::String(s) => s.clone(), + PropertyKey::Number(n) => crate::number::to_string(*n).encode_utf16().collect(), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PropertyValue { + Init(ExprId), + Get(FunctionId), + Set(FunctionId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Property { + pub key: PropertyKey, + pub value: PropertyValue, + pub span: Span, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Expr { + This, + Ident(String), + Null, + Bool(bool), + Number(f64), + String(Vec), + RegExp { + pattern: Vec, + flags: String, + }, + /// An array literal; `None` is a hole (`[1, , 3]`). + Array(Vec>), + Object(Vec), + Function(FunctionId), + /// `object.property`. + Member { + object: ExprId, + property: Name, + }, + /// `object[index]`. + Index { + object: ExprId, + index: ExprId, + }, + Call { + callee: ExprId, + args: Vec, + }, + New { + callee: ExprId, + args: Vec, + }, + Update { + op: UpdateOp, + prefix: bool, + target: ExprId, + }, + Unary { + op: UnaryOp, + arg: ExprId, + }, + Binary { + op: BinaryOp, + left: ExprId, + right: ExprId, + }, + Logical { + op: LogicalOp, + left: ExprId, + right: ExprId, + }, + Conditional { + test: ExprId, + consequent: ExprId, + alternate: ExprId, + }, + Assign { + op: AssignOp, + target: ExprId, + value: ExprId, + }, + /// Comma-separated expressions. + Sequence(Vec), + /// A parenthesized expression, kept so a rule about the bare form (a label, a directive) can + /// tell them apart. + Paren(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VarDecl { + pub name: Name, + pub init: Option, +} + +/// What a `for (… in …)` statement assigns each property name to. +#[derive(Clone, Debug, PartialEq)] +pub enum ForInTarget { + /// `for (var x in o)`, with the declaration's initializer if it has one. + Var(VarDecl), + Expr(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ForInit { + Var(Vec), + Expr(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SwitchCase { + /// `None` for `default`. + pub test: Option, + pub body: Vec, + pub span: Span, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CatchClause { + pub param: Name, + pub body: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Stmt { + Var(Vec), + FunctionDecl(FunctionId), + Expr(ExprId), + Block(Vec), + Empty, + If { test: ExprId, consequent: StmtId, alternate: Option }, + DoWhile { body: StmtId, test: ExprId }, + While { test: ExprId, body: StmtId }, + For { init: Option, test: Option, update: Option, body: StmtId }, + ForIn { target: ForInTarget, object: ExprId, body: StmtId }, + Continue(Option), + Break(Option), + Return(Option), + With { object: ExprId, body: StmtId }, + Switch { discriminant: ExprId, cases: Vec }, + Labeled { label: Name, body: StmtId }, + Throw(ExprId), + Try { block: Vec, handler: Option, finalizer: Option> }, + Debugger, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FunctionKind { + Declaration, + Expression, + Getter, + Setter, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Function { + pub kind: FunctionKind, + pub name: Option, + pub params: Vec, + pub body: Vec, + /// The function's code is strict mode code: its own directive prologue holds `"use strict"`, + /// or the code around it is strict. + pub strict: bool, + pub span: Span, +} + +/// The arenas of a parsed script. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ast { + pub exprs: Vec<(Expr, Span)>, + pub stmts: Vec<(Stmt, Span)>, + pub functions: Vec, +} + +impl Ast { + pub fn expr(&self, id: ExprId) -> Option<&Expr> { + self.exprs.get(id.0 as usize).map(|(e, _)| e) + } + + pub fn expr_span(&self, id: ExprId) -> Span { + self.exprs.get(id.0 as usize).map(|(_, s)| *s).unwrap_or_default() + } + + pub fn stmt(&self, id: StmtId) -> Option<&Stmt> { + self.stmts.get(id.0 as usize).map(|(s, _)| s) + } + + pub fn stmt_span(&self, id: StmtId) -> Span { + self.stmts.get(id.0 as usize).map(|(_, s)| *s).unwrap_or_default() + } + + pub fn function(&self, id: FunctionId) -> Option<&Function> { + self.functions.get(id.0 as usize) + } + + /// The expression with any parentheses around it removed. + pub fn unparenthesized(&self, mut id: ExprId) -> ExprId { + while let Some(Expr::Paren(inner)) = self.expr(id) { + id = *inner; + } + id + } + + pub(crate) fn push_expr(&mut self, expr: Expr, span: Span) -> ExprId { + self.exprs.push((expr, span)); + ExprId(u32::try_from(self.exprs.len() - 1).unwrap_or(u32::MAX)) + } + + pub(crate) fn push_stmt(&mut self, stmt: Stmt, span: Span) -> StmtId { + self.stmts.push((stmt, span)); + StmtId(u32::try_from(self.stmts.len() - 1).unwrap_or(u32::MAX)) + } + + pub(crate) fn push_function(&mut self, function: Function) -> FunctionId { + self.functions.push(function); + FunctionId(u32::try_from(self.functions.len() - 1).unwrap_or(u32::MAX)) + } +} + +/// A parsed script. +#[derive(Clone, Debug, PartialEq)] +pub struct Program { + pub ast: Ast, + pub body: Vec, + /// The script is strict mode code (its directive prologue holds `"use strict"`, or it was + /// parsed with [`crate::Options::strict`]). + pub strict: bool, + /// Its preprocessor lines, in source order. + pub directives: Vec, + pub(crate) lines: crate::LineTable, +} + +impl Program { + /// The line and column (both from 1, the column in UTF-16 code units) of a source offset. + pub fn line_column(&self, offset: u32) -> (u32, u32) { + self.lines.line_column(offset) + } + + /// The number of lines in the source: its line terminators plus one for a last line without + /// one. + pub fn line_count(&self) -> u32 { + self.lines.line_count() + } +} diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs new file mode 100644 index 000000000..75c0b92aa --- /dev/null +++ b/crates/js/src/chars.rs @@ -0,0 +1,67 @@ +//! Character classes of ECMA-262 5.1 section 7: white space, line terminators and the characters +//! identifiers are made of. + +use unicode_general_category::{GeneralCategory as G, get_general_category}; + +/// A line terminator: LF, CR, U+2028 or U+2029 (section 7.3). +pub fn is_line_terminator(c: u32) -> bool { + matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029) +} + +/// White space (section 7.2): tab, vertical tab, form feed, space, no-break space, the byte order +/// mark and the other space separators (Unicode category Zs). +pub fn is_whitespace(c: u32) -> bool { + matches!(c, 0x09 | 0x0B | 0x0C | 0x20 | 0xA0 | 0xFEFF | 0x1680 | 0x2000..=0x200A | 0x202F | 0x205F | 0x3000) +} + +/// A character an identifier may start with: a letter (categories Lu, Ll, Lt, Lm, Lo and Nl), +/// `$` or `_` (section 7.6). `\u` escapes are decoded before this check. +pub fn is_id_start(c: u32) -> bool { + match c { + 0x24 | 0x5F => true, + 0x41..=0x5A | 0x61..=0x7A => true, + 0..=0x7F => false, + // U+2E2F VERTICAL TILDE is a letter (Lm) that Unicode leaves out of identifiers. + 0x2E2F => false, + _ => category(c).is_some_and(|g| { + matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber) + }), + } +} + +/// A character an identifier may hold after its first: those it may start with, decimal digits +/// (Nd), combining marks (Mn, Mc), connector punctuation (Pc), and the zero width non-joiner and +/// joiner. +pub fn is_id_part(c: u32) -> bool { + match c { + 0x30..=0x39 => true, + 0x200C | 0x200D => true, + 0x2E2F => false, + _ if is_id_start(c) => true, + 0..=0x7F => false, + _ => category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)), + } +} + +/// The Unicode general category of a code point; `None` for a lone surrogate. +fn category(c: u32) -> Option { + char::from_u32(c).map(get_general_category) +} + +/// The value of a hexadecimal digit. +pub fn hex_value(c: u32) -> Option { + match c { + 0x30..=0x39 => Some(c - 0x30), + 0x41..=0x46 => Some(c - 0x41 + 10), + 0x61..=0x66 => Some(c - 0x61 + 10), + _ => None, + } +} + +pub fn is_decimal_digit(c: u32) -> bool { + (0x30..=0x39).contains(&c) +} + +pub fn is_octal_digit(c: u32) -> bool { + (0x30..=0x37).contains(&c) +} diff --git a/crates/js/src/error.rs b/crates/js/src/error.rs new file mode 100644 index 000000000..9c17bf8be --- /dev/null +++ b/crates/js/src/error.rs @@ -0,0 +1,29 @@ +//! The error a script that cannot be read gives: a `SyntaxError` with its message and where in the +//! source it is. + +use std::fmt; + +/// A script that does not follow the grammar, or breaks one of its early-error rules. `line` and +/// `column` count from 1; the column counts UTF-16 code units from the start of the line, as +/// JavaScript source positions do. `offset` is the UTF-16 offset from the start of the source. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SyntaxError { + pub message: String, + pub line: u32, + pub column: u32, + pub offset: u32, +} + +impl fmt::Display for SyntaxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SyntaxError: {} (line {}, column {})", self.message, self.line, self.column) + } +} + +impl std::error::Error for SyntaxError {} + +/// The message of the error nesting deeper than [`crate::Options::max_depth`] gives. +pub const TOO_DEEP: &str = "nesting too deep"; + +/// The message of the error a source longer than [`crate::Options::max_source_units`] gives. +pub const TOO_LONG: &str = "source too long"; diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs new file mode 100644 index 000000000..9b16b16c7 --- /dev/null +++ b/crates/js/src/lexer.rs @@ -0,0 +1,637 @@ +//! The lexer (ECMA-262 5.1 section 7): source text in UTF-16 code units into tokens, with each +//! token's line and column and whether a line terminator comes before it. +//! +//! A `/` starts a division punctuator or a regular expression literal depending on where the +//! grammar is (section 7, the InputElementDiv and InputElementRegExp goals). The lexer reads a +//! token for the goal it is given; the parser reads tokens for the division goal and asks for a +//! token to be read again as a regular expression where an expression starts +//! ([`Lexer::rescan_regexp`]). +//! +//! With [`crate::Options::preprocessor_lines`], a `#name value` line, or a `//@name value` comment +//! that names a directive, at the start of a line (only blanks before it) gives a +//! [`TokenKind::Directive`] token. +//! +//! The lexer moves forward through the source one character at a time and never recurses. + +use crate::chars::{hex_value, is_decimal_digit, is_id_part, is_id_start, is_line_terminator, is_octal_digit, is_whitespace}; +use crate::error::SyntaxError; +use crate::regexp; +use crate::token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; + +/// Which goal symbol the next token is read for: whether a `/` there divides or starts a regular +/// expression literal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Goal { + Div, + RegExp, +} + +const BACKSLASH: u32 = b'\\' as u32; +const SLASH: u32 = b'/' as u32; +const STAR: u32 = b'*' as u32; +const DOT: u32 = b'.' as u32; +const CR: u32 = 0x0D; +const LF: u32 = 0x0A; + +/// The lexer's state. It is small and cheap to clone, which is how the parser looks one token +/// ahead. +#[derive(Clone, Debug)] +pub struct Lexer<'s> { + src: &'s [u16], + pos: usize, + /// The current line (from 1) and the offset where it starts. + line: u32, + line_start: usize, + /// Only blanks since the start of the line: a preprocessor line may start here. + blank_line: bool, + preprocessor_lines: bool, + /// The deepest nesting of groups a regular expression literal may have. + max_depth: usize, +} + +impl<'s> Lexer<'s> { + /// A lexer of `src`. `preprocessor_lines` turns on the reading of preprocessor lines; + /// `max_depth` limits how deeply a regular expression literal's groups may nest. + pub fn new(src: &'s [u16], preprocessor_lines: bool, max_depth: usize) -> Self { + Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth } + } + + /// The code unit at `pos`, as a code point value. + fn unit(&self, pos: usize) -> Option { + self.src.get(pos).map(|&u| u32::from(u)) + } + + fn peek(&self) -> Option { + self.unit(self.pos) + } + + fn peek_at(&self, n: usize) -> Option { + self.unit(self.pos + n) + } + + /// The code point at `pos` (a surrogate pair read as one) and how many units it takes. + fn code_point(&self, pos: usize) -> Option<(u32, usize)> { + let hi = self.unit(pos)?; + if (0xD800..0xDC00).contains(&hi) + && let Some(lo) = self.unit(pos + 1).filter(|lo| (0xDC00..0xE000).contains(lo)) + { + return Some((0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00), 2)); + } + Some((hi, 1)) + } + + fn column_of(&self, pos: usize) -> u32 { + u32::try_from(pos.saturating_sub(self.line_start) + 1).unwrap_or(u32::MAX) + } + + fn offset(pos: usize) -> u32 { + u32::try_from(pos).unwrap_or(u32::MAX) + } + + /// An error at `pos` on the current line. + fn error_at(&self, pos: usize, message: impl Into) -> SyntaxError { + SyntaxError { message: message.into(), line: self.line, column: self.column_of(pos), offset: Self::offset(pos) } + } + + /// An error at a position recorded earlier. + fn error_from(start: (usize, u32, u32), message: impl Into) -> SyntaxError { + SyntaxError { message: message.into(), line: start.1, column: start.2, offset: Self::offset(start.0) } + } + + /// Reads a line terminator at `pos` (CR LF as one), counting the line. False when there is + /// none. + fn line_terminator(&mut self) -> bool { + match self.peek() { + Some(CR) if self.peek_at(1) == Some(LF) => self.pos += 2, + Some(c) if is_line_terminator(c) => self.pos += 1, + _ => return false, + } + self.line = self.line.saturating_add(1); + self.line_start = self.pos; + self.blank_line = true; + true + } + + /// The next token, read for `goal`. + pub fn next_token(&mut self, goal: Goal) -> Result { + let newline_before = match self.skip_trivia()? { + Trivia::Directive(token) => return Ok(*token), + Trivia::Skipped { newline } => newline, + }; + let start = self.pos; + let (line, column) = (self.line, self.column_of(start)); + let Some(c) = self.peek() else { + return Ok(self.token(TokenKind::Eof, start, line, column, newline_before)); + }; + self.blank_line = false; + let mut token = if c == BACKSLASH || is_id_start(c) || self.code_point(start).is_some_and(|(cp, n)| n == 2 && is_id_start(cp)) { + self.identifier(start, line, column)? + } else if is_decimal_digit(c) || (c == DOT && self.peek_at(1).is_some_and(is_decimal_digit)) { + self.number(start, line, column)? + } else if c == u32::from(b'"') || c == u32::from(b'\'') { + self.string(start, line, column)? + } else if c == SLASH && goal == Goal::RegExp { + self.regexp(start, line, column)? + } else { + let punct = self.punct().ok_or_else(|| self.error_at(start, unexpected_character(c)))?; + self.token(TokenKind::Punct(punct), start, line, column, false) + }; + // A string continued over a line ends on a new line, but not at its start. + self.blank_line = false; + token.newline_before = newline_before; + Ok(token) + } + + /// Reads the token `token` again as a regular expression literal: the parser read a `/` or + /// `/=` where an expression starts. + pub fn rescan_regexp(&mut self, token: &Token) -> Result { + let start = usize::try_from(token.span.start).unwrap_or(usize::MAX); + self.pos = start; + self.line = token.line; + self.line_start = start.saturating_sub(usize::try_from(token.column.saturating_sub(1)).unwrap_or(0)); + self.blank_line = false; + let mut regexp = self.regexp(start, token.line, token.column)?; + regexp.newline_before = token.newline_before; + Ok(regexp) + } + + fn token(&self, kind: TokenKind, start: usize, line: u32, column: u32, newline_before: bool) -> Token { + Token { + kind, + span: Span::new(Self::offset(start), Self::offset(self.pos)), + line, + column, + newline_before, + escaped: false, + legacy_octal: false, + } + } + + /// Skips white space, line terminators and comments, and reads a preprocessor line when one + /// starts here. + fn skip_trivia(&mut self) -> Result { + let mut newline = false; + while let Some(c) = self.peek() { + if is_whitespace(c) { + self.pos += 1; + } else if self.line_terminator() { + newline = true; + } else if c == SLASH && self.peek_at(1) == Some(SLASH) { + if self.blank_line && self.preprocessor_lines && self.names_directive(self.pos + 2) { + return Ok(Trivia::Directive(Box::new(self.directive("//@", newline)))); + } + while self.peek().is_some_and(|c| !is_line_terminator(c)) { + self.pos += 1; + } + } else if c == SLASH && self.peek_at(1) == Some(STAR) { + newline |= self.block_comment()?; + // The comment's end is on this line, so a `#` after it is not at the line's start. + self.blank_line = false; + } else if c == u32::from(b'#') && self.blank_line && self.preprocessor_lines && self.peek_at(1).is_some_and(is_name_char) { + return Ok(Trivia::Directive(Box::new(self.directive("#", newline)))); + } else { + break; + } + } + Ok(Trivia::Skipped { newline }) + } + + /// Skips a `/* */` comment; true when it holds a line terminator. + fn block_comment(&mut self) -> Result { + let start = (self.pos, self.line, self.column_of(self.pos)); + self.pos += 2; + let mut newline = false; + loop { + match self.peek() { + None => return Err(Self::error_from(start, "unterminated comment")), + Some(STAR) if self.peek_at(1) == Some(SLASH) => { + self.pos += 2; + return Ok(newline); + } + Some(_) if self.line_terminator() => newline = true, + Some(_) => self.pos += 1, + } + } + } + + /// `//@` at `pos - 2` is followed by a directive's name. + fn names_directive(&self, pos: usize) -> bool { + if self.unit(pos) != Some(u32::from(b'@')) { + return false; + } + let name: String = self.src[pos + 1..].iter().take_while(|&&u| is_name_char(u32::from(u))).map(|&u| char::from(u as u8)).collect(); + DirectiveName::from_name(&name).is_some() + } + + /// Reads a preprocessor line written with `form` (`#` or `//@`), up to the end of the line. + fn directive(&mut self, form: &'static str, newline_before: bool) -> Token { + let start = self.pos; + let (line, column) = (self.line, self.column_of(start)); + self.pos += form.encode_utf16().count(); + let name_start = self.pos; + while self.peek().is_some_and(is_name_char) { + self.pos += 1; + } + let name = String::from_utf16_lossy(&self.src[name_start..self.pos]); + let value_start = self.pos; + while self.peek().is_some_and(|c| !is_line_terminator(c)) { + self.pos += 1; + } + let value = String::from_utf16_lossy(&self.src[value_start..self.pos]).trim().to_string(); + let directive = Directive { + known: DirectiveName::from_name(&name), + name, + value, + form, + span: Span::new(Self::offset(start), Self::offset(self.pos)), + line, + }; + self.blank_line = false; + self.token(TokenKind::Directive(directive), start, line, column, newline_before) + } + + /// Reads an identifier or a keyword (section 7.6), `\u` escapes included. + fn identifier(&mut self, start: usize, line: u32, column: u32) -> Result { + let mut name = String::new(); + let mut escaped = false; + let mut first = true; + loop { + let at = self.pos; + let (c, len, is_escape) = match self.code_point(at) { + Some((BACKSLASH, _)) => { + let c = self.identifier_escape()?; + (c, 0, true) + } + Some((c, len)) => (c, len, false), + None => break, + }; + let ok = if first { is_id_start(c) } else { is_id_part(c) }; + if !ok { + if is_escape { + return Err(self.error_at(at, "invalid character in identifier escape")); + } + break; + } + self.pos += len; + escaped |= is_escape; + first = false; + match char::from_u32(c) { + Some(ch) => name.push(ch), + None => return Err(self.error_at(at, "invalid character in identifier")), + } + } + let kind = match Keyword::from_word(&name) { + Some(k) if !escaped => TokenKind::Keyword(k), + _ => TokenKind::Ident(name), + }; + let mut token = self.token(kind, start, line, column, false); + token.escaped = escaped; + Ok(token) + } + + /// Reads a `\uXXXX` escape in an identifier and returns its code point. + fn identifier_escape(&mut self) -> Result { + let at = self.pos; + if self.peek_at(1) != Some(u32::from(b'u')) { + return Err(self.error_at(at, "invalid escape in identifier")); + } + self.pos += 2; + self.unicode_escape().ok_or_else(|| self.error_at(at, "invalid Unicode escape sequence")) + } + + /// After `\u`: four hexadecimal digits, or a code point in braces (`\u{1F600}`, ECMA-262 2015 + /// section 11.8.4) up to U+10FFFF. + fn unicode_escape(&mut self) -> Option { + if self.peek() != Some(u32::from(b'{')) { + return self.hex_digits(4); + } + let mut n = 1; + let mut value: u32 = 0; + while let Some(d) = self.peek_at(n).and_then(hex_value) { + value = value.checked_mul(16)?.checked_add(d).filter(|&v| v <= 0x10FFFF)?; + n += 1; + } + if n == 1 || self.peek_at(n) != Some(u32::from(b'}')) { + return None; + } + self.pos += n + 1; + Some(value) + } + + /// Reads `n` hexadecimal digits and returns their value, or `None` (leaving the position) when + /// fewer follow. + fn hex_digits(&mut self, n: usize) -> Option { + let mut value = 0; + for i in 0..n { + value = value * 16 + hex_value(self.peek_at(i)?)?; + } + self.pos += n; + Some(value) + } + + /// Reads a numeric literal (section 7.8.3, with the legacy octal literals of Annex B). + fn number(&mut self, start: usize, line: u32, column: u32) -> Result { + let mut legacy_octal = false; + let value = if self.peek() == Some(u32::from(b'0')) && self.peek_at(1).is_some_and(|c| c == u32::from(b'x') || c == u32::from(b'X')) { + self.pos += 2; + self.radix_digits(16).ok_or_else(|| self.error_at(start, "hexadecimal literal without digits"))? + } else if self.peek() == Some(u32::from(b'0')) && self.peek_at(1).is_some_and(is_decimal_digit) { + legacy_octal = true; + self.legacy_octal_or_decimal() + } else { + self.decimal() + }; + if self.peek().is_some_and(|c| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) { + return Err(self.error_at(self.pos, "identifier starts immediately after numeric literal")); + } + let mut token = self.token(TokenKind::Number(value), start, line, column, false); + token.legacy_octal = legacy_octal; + Ok(token) + } + + /// Reads digits in `radix` (8 or 16) and returns their value, or `None` when there are none. + fn radix_digits(&mut self, radix: u32) -> Option { + let begin = self.pos; + let mut exact: u128 = 0; + let mut value = 0.0_f64; + let mut overflow = false; + while let Some(d) = self.peek().and_then(hex_value).filter(|&d| d < radix) { + match exact.checked_mul(u128::from(radix)).and_then(|v| v.checked_add(u128::from(d))) { + Some(v) if !overflow => exact = v, + _ => { + if !overflow { + value = exact as f64; + overflow = true; + } + value = value * f64::from(radix) + f64::from(d); + } + } + self.pos += 1; + } + if self.pos == begin { + return None; + } + Some(if overflow { value } else { exact as f64 }) + } + + /// After `0` and a digit: an octal literal when every digit is octal, else a decimal literal + /// whose integer part starts with `0` (`08`, `09.5`). + fn legacy_octal_or_decimal(&mut self) -> f64 { + let begin = self.pos; + let mut end = begin; + while self.unit(end).is_some_and(is_decimal_digit) { + end += 1; + } + if self.src[begin..end].iter().all(|&u| is_octal_digit(u32::from(u))) { + self.pos += 1; + return self.radix_digits(8).unwrap_or(0.0); + } + self.decimal() + } + + /// Reads a decimal literal: digits, an optional fraction and an optional exponent. + fn decimal(&mut self) -> f64 { + let mut text = String::new(); + self.push_digits(&mut text); + if text.is_empty() { + text.push('0'); + } + if self.peek() == Some(DOT) { + self.pos += 1; + text.push('.'); + let before = text.len(); + self.push_digits(&mut text); + if text.len() == before { + text.push('0'); + } + } + if self.peek().is_some_and(|c| c == u32::from(b'e') || c == u32::from(b'E')) { + let sign = self.peek_at(1).filter(|&c| c == u32::from(b'+') || c == u32::from(b'-')); + let digit_at = if sign.is_some() { 2 } else { 1 }; + if self.peek_at(digit_at).is_some_and(is_decimal_digit) { + self.pos += digit_at; + text.push('e'); + if sign == Some(u32::from(b'-')) { + text.push('-'); + } + self.push_digits(&mut text); + } + } + text.parse().unwrap_or(f64::NAN) + } + + fn push_digits(&mut self, text: &mut String) { + while let Some(c) = self.peek().filter(|&c| is_decimal_digit(c)) { + text.push(char::from(c as u8)); + self.pos += 1; + } + } + + /// Reads a string literal (section 7.8.4, with the octal escapes of Annex B) and returns its + /// value. + fn string(&mut self, start: usize, line: u32, column: u32) -> Result { + let quote = self.peek().unwrap_or_default(); + self.pos += 1; + let mut value = Vec::new(); + let mut legacy_octal = false; + loop { + match self.peek() { + None => return Err(Self::error_from((start, line, column), "unterminated string literal")), + // U+2028 and U+2029 may stand in a string, as ECMA-262 2019 allows; they still + // start a new line for positions. + Some(c @ (0x2028 | 0x2029)) => { + value.push(c as u16); + self.line_terminator(); + } + Some(c) if is_line_terminator(c) => { + return Err(Self::error_from((start, line, column), "unterminated string literal")); + } + Some(c) if c == quote => { + self.pos += 1; + break; + } + Some(BACKSLASH) => legacy_octal |= self.string_escape(&mut value)?, + Some(c) => { + value.push(c as u16); + self.pos += 1; + } + } + } + let mut token = self.token(TokenKind::String(value), start, line, column, false); + token.legacy_octal = legacy_octal; + Ok(token) + } + + /// Reads an escape in a string literal into `value`; true for an octal escape (`\1`, `\08`) + /// or `\8` and `\9`. + fn string_escape(&mut self, value: &mut Vec) -> Result { + let at = self.pos; + self.pos += 1; + let Some(c) = self.peek() else { + return Err(self.error_at(at, "unterminated string literal")); + }; + if self.line_terminator() { + return Ok(false); + } + let single = match char::from_u32(c) { + Some('b') => Some(0x08), + Some('t') => Some(0x09), + Some('n') => Some(0x0A), + Some('v') => Some(0x0B), + Some('f') => Some(0x0C), + Some('r') => Some(0x0D), + _ => None, + }; + if let Some(unit) = single { + value.push(unit); + self.pos += 1; + return Ok(false); + } + match char::from_u32(c) { + Some('x') => { + self.pos += 1; + let unit = self.hex_digits(2).ok_or_else(|| self.error_at(at, "invalid hexadecimal escape sequence"))?; + value.push(unit as u16); + Ok(false) + } + Some('u') => { + self.pos += 1; + let code_point = self.unicode_escape().ok_or_else(|| self.error_at(at, "invalid Unicode escape sequence"))?; + match char::from_u32(code_point) { + Some(ch) if code_point > 0xFFFF => value.extend(ch.encode_utf16(&mut [0; 2]).iter()), + _ => value.push(code_point as u16), + } + Ok(false) + } + Some('0') if !self.peek_at(1).is_some_and(is_decimal_digit) => { + self.pos += 1; + value.push(0); + Ok(false) + } + Some('0'..='7') => { + value.push(self.octal_escape(c)); + Ok(true) + } + Some('8' | '9') => { + self.pos += 1; + value.push(c as u16); + Ok(true) + } + _ => { + self.pos += 1; + value.push(c as u16); + Ok(false) + } + } + } + + /// Reads an octal escape whose first digit is `first`: up to three digits when it is 0 to 3, + /// up to two when it is 4 to 7. + fn octal_escape(&mut self, first: u32) -> u16 { + let max = if first <= u32::from(b'3') { 3 } else { 2 }; + let mut value = 0; + let mut n = 0; + while n < max + && let Some(d) = self.peek().filter(|&d| is_octal_digit(d)) + { + value = value * 8 + (d - u32::from(b'0')); + self.pos += 1; + n += 1; + } + value as u16 + } + + /// Reads a regular expression literal (section 7.8.5) and checks its flags and pattern. + fn regexp(&mut self, start: usize, line: u32, column: u32) -> Result { + let at = (start, line, column); + self.pos = start + 1; + let mut in_class = false; + loop { + match self.peek() { + None => return Err(Self::error_from(at, "unterminated regular expression literal")), + Some(c) if is_line_terminator(c) => return Err(Self::error_from(at, "unterminated regular expression literal")), + Some(BACKSLASH) => { + if self.peek_at(1).is_none_or(is_line_terminator) { + return Err(Self::error_from(at, "unterminated regular expression literal")); + } + self.pos += 2; + } + Some(c) if c == u32::from(b'[') => { + in_class = true; + self.pos += 1; + } + Some(c) if c == u32::from(b']') => { + in_class = false; + self.pos += 1; + } + Some(SLASH) if !in_class => break, + Some(_) => self.pos += 1, + } + } + let pattern = self.src[start + 1..self.pos].to_vec(); + self.pos += 1; + let mut flags = String::new(); + while let Some((c, len)) = self.code_point(self.pos) { + if c == BACKSLASH { + return Err(Self::error_from(at, "invalid regular expression flags")); + } + if !is_id_part(c) { + break; + } + flags.push(char::from_u32(c).unwrap_or('\u{fffd}')); + self.pos += len; + } + if !valid_flags(&flags) { + return Err(Self::error_from(at, "invalid regular expression flags")); + } + if let Err(message) = regexp::check(&pattern, self.max_depth) { + return Err(Self::error_from(at, format!("invalid regular expression: {message}"))); + } + Ok(self.token(TokenKind::RegExp { pattern, flags }, start, line, column, false)) + } + + /// Reads the longest punctuator that starts here. + fn punct(&mut self) -> Option { + let rest = &self.src[self.pos..]; + for (spelling, punct) in Punct::ALL { + let bytes = spelling.as_bytes(); + if rest.len() >= bytes.len() && rest.iter().zip(bytes).all(|(&u, &b)| u == u16::from(b)) { + self.pos += bytes.len(); + return Some(*punct); + } + } + None + } +} + +/// What skipping the blanks and comments before a token found. +enum Trivia { + Skipped { newline: bool }, + Directive(Box), +} + +/// A character of a directive's name. +fn is_name_char(c: u32) -> bool { + c < 0x80 && (char::from(c as u8).is_ascii_alphanumeric() || c == u32::from(b'_')) +} + +/// The flags `g`, `i` and `m`, each at most once (section 15.10.4.1). +fn valid_flags(flags: &str) -> bool { + let mut seen = [false; 3]; + flags.chars().all(|c| { + let i = match c { + 'g' => 0, + 'i' => 1, + 'm' => 2, + _ => return false, + }; + !std::mem::replace(&mut seen[i], true) + }) +} + +fn unexpected_character(c: u32) -> String { + match char::from_u32(c) { + Some(ch) if !ch.is_control() => format!("unexpected character '{ch}'"), + _ => format!("unexpected character U+{c:04X}"), + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs new file mode 100644 index 000000000..4d8d32271 --- /dev/null +++ b/crates/js/src/lib.rs @@ -0,0 +1,149 @@ +//! JavaScript for VectorCraft's scripts: an ECMAScript 5.1 lexer and parser +//! (), with the legacy forms of Annex B that older scripts +//! use (octal literals and escapes outside strict mode, function declarations in blocks, the +//! lenient regular expression patterns). +//! +//! [`parse`] reads a script into a [`Program`]: a syntax tree whose expressions, statements and +//! functions live in arenas and carry their source spans. A script that breaks the grammar or one +//! of its early-error rules (section 16: strict mode's restrictions, duplicate parameters in strict +//! code, invalid assignment targets, `break` and `continue` without a target, regular expression +//! literals that are not valid) gives a [`SyntaxError`] with its line and column. +//! +//! With [`Options::preprocessor_lines`], the lexer also reads the preprocessor lines scripts begin +//! with (`#include`, `#includepath`, `#script`, `#strict`, `#target`, `#targetengine`) into +//! [`Directive`]s. They are kept in [`Program::directives`] and never run. +//! +//! The lexer reads the source as UTF-16 code units, as JavaScript does: columns and spans count +//! code units. Nesting is counted: input nested deeper than [`Options::max_depth`] gives a +//! `SyntaxError` ("nesting too deep") instead of exhausting the stack, binary operators, `if` … +//! `else if` chains, labels and prefix operators are read with loops, and the syntax tree is +//! dropped without recursion. +#![forbid(unsafe_code)] + +pub mod ast; +mod chars; +mod error; +mod lexer; +mod number; +mod parser; +mod regexp; +mod token; + +pub use ast::{ + AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, +}; +pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; +pub use lexer::{Goal, Lexer}; +pub use number::to_string as number_to_string; +pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; + +/// How a script is read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Options { + /// The script is strict mode code from its start, as code passed to a strict `eval` is. + pub strict: bool, + /// Read preprocessor lines (`#target …` and the other directives) into [`Directive`]s. Off, + /// a `#` is an unexpected character, as ECMA-262 has it. + pub preprocessor_lines: bool, + /// How deeply statements, expressions, functions and regular expression groups may nest. + pub max_depth: usize, + /// The most UTF-16 code units a source may hold. + pub max_source_units: usize, +} + +impl Default for Options { + fn default() -> Self { + Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20 } + } +} + +/// The default [`Options::max_depth`]: deeper than scripts nest, and shallow enough for the stack +/// of a wasm build or a secondary thread. +pub const DEFAULT_MAX_DEPTH: usize = 200; + +/// Parses `source` as a script. +pub fn parse(source: &str, options: &Options) -> Result { + let units = to_utf16(source, options)?; + parse_utf16(&units, options) +} + +/// Parses `source`, in UTF-16 code units, as a script. +pub fn parse_utf16(source: &[u16], options: &Options) -> Result { + check_length(source.len(), options)?; + parser::Parser::new(source, options, false)?.parse_program().map(|(program, _)| program) +} + +/// The tokens of `source`, in order, as the parser reads them: preprocessor lines included, and +/// each `/` read as division or as the start of a regular expression literal as the grammar +/// decides. The source must be a valid script. +pub fn tokenize(source: &str, options: &Options) -> Result, SyntaxError> { + let units = to_utf16(source, options)?; + check_length(units.len(), options)?; + parser::Parser::new(&units, options, true)?.parse_program().map(|(_, tokens)| tokens) +} + +fn to_utf16(source: &str, options: &Options) -> Result, SyntaxError> { + // A source's UTF-16 length is at most its UTF-8 length; count only a source that may be too + // long. + if source.len() > options.max_source_units { + check_length(source.encode_utf16().count(), options)?; + } + Ok(source.encode_utf16().collect()) +} + +fn check_length(units: usize, options: &Options) -> Result<(), SyntaxError> { + if units > options.max_source_units || u32::try_from(units).is_err() { + return Err(SyntaxError { message: TOO_LONG.into(), line: 1, column: 1, offset: 0 }); + } + Ok(()) +} + +/// Where each line of a source starts, for turning offsets into lines and columns. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct LineTable { + starts: Vec, + /// The source ends with a line terminator. + ends_with_break: bool, + empty: bool, +} + +impl LineTable { + pub(crate) fn new(src: &[u16]) -> Self { + let mut starts = vec![0]; + let mut i = 0; + while let Some(&u) = src.get(i) { + i += 1; + let terminator = match u { + 0x0D if src.get(i) == Some(&0x0A) => { + i += 1; + true + } + 0x0A | 0x0D | 0x2028 | 0x2029 => true, + _ => false, + }; + if terminator { + starts.push(u32::try_from(i).unwrap_or(u32::MAX)); + } + } + let ends_with_break = src.last().is_some_and(|&u| matches!(u, 0x0A | 0x0D | 0x2028 | 0x2029)); + Self { starts, ends_with_break, empty: src.is_empty() } + } + + pub(crate) fn line_column(&self, offset: u32) -> (u32, u32) { + let line = self.starts.partition_point(|&s| s <= offset).max(1); + let start = self.starts.get(line - 1).copied().unwrap_or(0); + (u32::try_from(line).unwrap_or(u32::MAX), offset.saturating_sub(start) + 1) + } + + pub(crate) fn line_count(&self) -> u32 { + if self.empty { + return 0; + } + let breaks = self.starts.len() - 1; + u32::try_from(breaks + usize::from(!self.ends_with_break)).unwrap_or(u32::MAX) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/js/src/number.rs b/crates/js/src/number.rs new file mode 100644 index 000000000..4afd48076 --- /dev/null +++ b/crates/js/src/number.rs @@ -0,0 +1,69 @@ +//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names. + +/// The string ToString gives for the number `x`: the shortest decimal digits that read back as +/// `x`, laid out as section 9.8.1 says. +pub fn to_string(x: f64) -> String { + if x.is_nan() { + return "NaN".into(); + } + if x == 0.0 { + return "0".into(); + } + if x.is_infinite() { + return if x > 0.0 { "Infinity".into() } else { "-Infinity".into() }; + } + if x < 0.0 { + return format!("-{}", to_string(-x)); + } + // Rust's `{:e}` writes the shortest digits that round-trip: `d.ddde±x`. + let sci = format!("{x:e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect(); + let k = digits.len() as i64; + let n = exp.parse::().unwrap_or(0) + 1; + if k <= n && n <= 21 { + let zeros = usize::try_from(n - k).unwrap_or(0); + format!("{digits}{}", "0".repeat(zeros)) + } else if 0 < n && n <= 21 { + let (int, frac) = digits.split_at_checked(usize::try_from(n).unwrap_or(0)).unwrap_or((&digits, "")); + format!("{int}.{frac}") + } else if -6 < n && n <= 0 { + let zeros = usize::try_from(-n).unwrap_or(0); + format!("0.{}{digits}", "0".repeat(zeros)) + } else { + let e = n - 1; + let sign = if e < 0 { '-' } else { '+' }; + let (first, rest) = digits.split_at_checked(1).unwrap_or((&digits, "")); + if rest.is_empty() { format!("{first}e{sign}{}", e.abs()) } else { format!("{first}.{rest}e{sign}{}", e.abs()) } + } +} + +#[cfg(test)] +mod tests { + use super::to_string; + + #[test] + fn numbers_print_as_section_9_8_1_lays_them_out() { + let cases: &[(f64, &str)] = &[ + (0.0, "0"), + (-0.0, "0"), + (1.0, "1"), + (-1.5, "-1.5"), + (123.456, "123.456"), + (1e21, "1e+21"), + (1e20, "100000000000000000000"), + (0.000001, "0.000001"), + (1e-7, "1e-7"), + (1.5e-7, "1.5e-7"), + (0.1 + 0.2, "0.30000000000000004"), + (f64::NAN, "NaN"), + (f64::INFINITY, "Infinity"), + (f64::NEG_INFINITY, "-Infinity"), + (5e-324, "5e-324"), + (1.7976931348623157e308, "1.7976931348623157e+308"), + ]; + for (x, want) in cases { + assert_eq!(to_string(*x), *want, "{x:?}"); + } + } +} diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs new file mode 100644 index 000000000..d56b75617 --- /dev/null +++ b/crates/js/src/parser.rs @@ -0,0 +1,1236 @@ +//! The parser: tokens into a [`Program`] (ECMA-262 5.1 sections 11 to 14), with the early errors +//! of section 16 and of strict mode (Annex C). +//! +//! It is a recursive-descent parser whose recursion is counted: each statement, assignment +//! expression, primary expression and function body it enters adds one to a depth, and going past +//! [`crate::Options::max_depth`] gives a `SyntaxError`. Binary operators are read with an operator +//! stack, and prefix operators, assignment chains, `else if` chains and labels with loops, so long +//! runs of them do not nest at all. + +use std::collections::{HashMap, HashSet}; + +use crate::LineTable; +use crate::ast::{ + AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionKind, LogicalOp, Name, Program, Property, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, +}; +use crate::error::{SyntaxError, TOO_DEEP}; +use crate::lexer::{Goal, Lexer}; +use crate::token::{Directive, Keyword, Punct, Span, Token, TokenKind, strict_reserved}; + +type Result = std::result::Result; + +/// What the code being parsed allows, saved and replaced at each function body. +#[derive(Clone, Debug, Default)] +struct Context { + strict: bool, + in_function: bool, + /// The labels in scope, each with whether it labels an iteration statement (`continue` may + /// name only those). Labels in scope have distinct names. + labels: HashMap, + /// Iteration statements open in the current function. + loops: u32, + /// Iteration and `switch` statements open in the current function. + breakables: u32, +} + +/// A binary operator, with the logical ones apart. +#[derive(Clone, Copy, Debug)] +enum BinKind { + Binary(BinaryOp), + Logical(LogicalOp), +} + +/// A prefix operator. +#[derive(Clone, Copy, Debug)] +enum Prefix { + Unary(UnaryOp), + Update(UpdateOp), +} + +pub(crate) struct Parser<'s> { + src: &'s [u16], + lex: Lexer<'s>, + lines: LineTable, + tok: Token, + /// Where the last token read ends. + prev_end: u32, + ast: Ast, + depth: usize, + max_depth: usize, + ctx: Context, + directives: Vec, + /// The tokens read, kept for [`crate::tokenize`]. + tokens: Option>, + /// The next statement may be a function declaration outside strict mode code: it is an `if` + /// branch or the body of a label in a statement list (ECMA-262 2015 Annex B.3.2 and B.3.4). + /// Read and cleared as each statement starts. + function_allowed: bool, + /// The next statement stands in a statement list, where a label's body may be a function + /// declaration. Read and cleared as each statement starts. + in_list: bool, +} + +impl<'s> Parser<'s> { + pub(crate) fn new(src: &'s [u16], options: &crate::Options, keep_tokens: bool) -> Result { + let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth); + let placeholder = + Token { kind: TokenKind::Eof, span: Span::default(), line: 1, column: 1, newline_before: false, escaped: false, legacy_octal: false }; + let mut parser = Self { + src, + lex, + lines: LineTable::new(src), + tok: placeholder, + prev_end: 0, + ast: Ast::default(), + depth: 0, + max_depth: options.max_depth, + ctx: Context { strict: options.strict, ..Context::default() }, + directives: Vec::new(), + tokens: keep_tokens.then(Vec::new), + function_allowed: false, + in_list: false, + }; + parser.tok = parser.next_significant()?; + Ok(parser) + } + + pub(crate) fn parse_program(mut self) -> Result<(Program, Vec)> { + let body = self.parse_body(false)?; + if self.tok.kind != TokenKind::Eof { + return Err(self.unexpected()); + } + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let program = Program { ast: self.ast, body, strict: self.ctx.strict, directives: self.directives, lines: self.lines }; + Ok((program, self.tokens.unwrap_or_default())) + } + + // ---- tokens ---- + + /// The next token that is not a preprocessor line; preprocessor lines are kept. + fn next_significant(&mut self) -> Result { + loop { + let token = self.lex.next_token(Goal::Div)?; + if let TokenKind::Directive(directive) = &token.kind { + self.directives.push(directive.clone()); + if let Some(tokens) = &mut self.tokens { + tokens.push(token); + } + continue; + } + return Ok(token); + } + } + + /// Reads the current token and moves to the next. + fn bump(&mut self) -> Result { + // Kept before reading on, so a preprocessor line after the token comes after it. + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let next = self.next_significant()?; + let token = std::mem::replace(&mut self.tok, next); + self.prev_end = token.span.end; + Ok(token) + } + + /// The token after the current one, without reading it. `None` when the lexer cannot read one + /// (reading it for real reports the error). + fn peek(&self) -> Option { + let mut lex = self.lex.clone(); + loop { + match lex.next_token(Goal::Div) { + Ok(Token { kind: TokenKind::Directive(_), .. }) => {} + Ok(token) => return Some(token), + Err(_) => return None, + } + } + } + + fn at(&self, p: Punct) -> bool { + self.tok.is_punct(p) + } + + fn at_keyword(&self, k: Keyword) -> bool { + self.tok.is_keyword(k) + } + + fn eat(&mut self, p: Punct) -> Result { + if self.at(p) { + self.bump()?; + return Ok(true); + } + Ok(false) + } + + fn expect(&mut self, p: Punct) -> Result { + if self.at(p) { + return self.bump(); + } + Err(self.error_at_token(&self.tok, format!("expected '{}' but found {}", p.as_str(), describe(&self.tok)))) + } + + fn expect_keyword(&mut self, k: Keyword) -> Result { + if self.at_keyword(k) { + return self.bump(); + } + Err(self.error_at_token(&self.tok, format!("expected '{}' but found {}", k.as_str(), describe(&self.tok)))) + } + + /// Ends a statement: a `;`, or one inserted before `}`, the end of the source or a token on a + /// new line (section 7.9). + fn semicolon(&mut self) -> Result<()> { + if self.eat(Punct::Semicolon)? || self.at(Punct::RBrace) || self.tok.kind == TokenKind::Eof || self.tok.newline_before { + return Ok(()); + } + Err(self.unexpected()) + } + + // ---- errors ---- + + fn error_at_offset(&self, offset: u32, message: impl Into) -> SyntaxError { + let (line, column) = self.lines.line_column(offset); + SyntaxError { message: message.into(), line, column, offset } + } + + fn error_at_token(&self, token: &Token, message: impl Into) -> SyntaxError { + SyntaxError { message: message.into(), line: token.line, column: token.column, offset: token.span.start } + } + + fn unexpected(&self) -> SyntaxError { + self.error_at_token(&self.tok, format!("unexpected {}", describe(&self.tok))) + } + + /// Counts one more level of nesting; past the limit, a `SyntaxError` at the current token. + fn enter(&mut self) -> Result<()> { + if self.depth >= self.max_depth { + return Err(self.error_at_token(&self.tok, TOO_DEEP)); + } + self.depth += 1; + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + fn span_from(&self, start: Span) -> Span { + Span::new(start.start, self.prev_end.max(start.start)) + } + + // ---- names ---- + + /// Checks a name used as an identifier: not a reserved word written with escapes, and not a + /// strict-mode reserved word in strict code. + fn check_identifier(&self, token: &Token, name: &str) -> Result<()> { + if token.escaped && Keyword::from_word(name).is_some() { + return Err(self.error_at_token(token, format!("keyword '{name}' must not contain escaped characters"))); + } + if self.ctx.strict && strict_reserved(name) { + return Err(self.error_at_token(token, format!("'{name}' is a reserved word in strict mode"))); + } + Ok(()) + } + + /// Reads an identifier that a declaration binds: checked as [`Self::check_identifier`] does, + /// and in strict code neither `eval` nor `arguments`. + fn binding_identifier(&mut self) -> Result { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected an identifier but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + if self.ctx.strict && is_eval_or_arguments(&name) { + return Err(self.error_at_token(&self.tok, format!("'{name}' cannot be declared in strict mode"))); + } + let token = self.bump()?; + Ok(Name { name, span: token.span }) + } + + // ---- program and function bodies ---- + + /// Reads source elements up to `}` (in a function body) or the end of the source, reading the + /// directive prologue at their start (section 14.1). A `"use strict"` directive makes the rest + /// strict mode code. + fn parse_body(&mut self, in_function: bool) -> Result> { + let mut body = Vec::new(); + let mut prologue = true; + // A string with an octal escape before a `"use strict"` directive in the same prologue. + let mut octal: Option = None; + while !(self.tok.kind == TokenKind::Eof || (in_function && self.at(Punct::RBrace))) { + if prologue && matches!(self.tok.kind, TokenKind::String(_)) { + let first = self.tok.clone(); + let stmt = self.parse_statement_item()?; + body.push(stmt); + if !self.is_directive(stmt, &first) { + prologue = false; + continue; + } + if self.is_use_strict(&first) { + self.ctx.strict = true; + if let Some(octal) = &octal { + return Err(self.error_at_token(octal, "octal escape sequences are not allowed in strict mode")); + } + } + if first.legacy_octal && octal.is_none() { + octal = Some(first); + } + continue; + } + prologue = false; + body.push(self.parse_statement_item()?); + } + Ok(body) + } + + /// Whether `stmt`, which started at `first`, is a directive: an expression statement made of + /// that one string literal. + fn is_directive(&self, stmt: StmtId, first: &Token) -> bool { + match self.ast.stmt(stmt) { + Some(Stmt::Expr(e)) => matches!(self.ast.expr(*e), Some(Expr::String(_))) && self.ast.expr_span(*e) == first.span, + _ => false, + } + } + + /// The string token is exactly `"use strict"` or `'use strict'`, without escapes or line + /// continuations. + fn is_use_strict(&self, token: &Token) -> bool { + let raw = self.src.get(token.span.start as usize..token.span.end as usize).unwrap_or_default(); + raw.len() == 12 && raw.get(1..11).is_some_and(|inner| inner.iter().copied().eq("use strict".encode_utf16())) + } + + // ---- statements ---- + + /// A source element: a function declaration or a statement. + fn parse_statement_item(&mut self) -> Result { + if self.at_keyword(Keyword::Function) { + self.enter()?; + let result = self.parse_function_declaration(); + self.leave(); + return result; + } + self.in_list = true; + self.parse_statement() + } + + fn parse_statement(&mut self) -> Result { + self.enter()?; + let result = self.statement(); + self.leave(); + result + } + + /// A statement where Annex B lets sloppy code put a function declaration: an `if` branch. + fn parse_branch(&mut self) -> Result { + self.function_allowed = !self.ctx.strict; + self.parse_statement() + } + + fn statement(&mut self) -> Result { + let start = self.tok.span; + let function_allowed = std::mem::take(&mut self.function_allowed); + let in_list = std::mem::take(&mut self.in_list); + match &self.tok.kind { + TokenKind::Keyword(Keyword::Function) => { + if !function_allowed { + let message = if self.ctx.strict { + "in strict mode code, functions can only be declared at top level or inside a block" + } else { + "a function declaration cannot stand here; put it in a block" + }; + return Err(self.error_at_token(&self.tok, message)); + } + self.parse_function_declaration() + } + TokenKind::Ident(_) if self.peek().is_some_and(|t| t.is_punct(Punct::Colon)) => self.parse_labeled(in_list), + _ => self.plain_statement(start), + } + } + + fn plain_statement(&mut self, start: Span) -> Result { + match &self.tok.kind { + TokenKind::Punct(Punct::LBrace) => { + let body = self.parse_block()?; + Ok(self.ast.push_stmt(Stmt::Block(body), self.span_from(start))) + } + TokenKind::Punct(Punct::Semicolon) => { + self.bump()?; + Ok(self.ast.push_stmt(Stmt::Empty, start)) + } + TokenKind::Keyword(k) => self.keyword_statement(*k, start), + _ => { + let expr = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Expr(expr), self.span_from(start))) + } + } + } + + fn keyword_statement(&mut self, k: Keyword, start: Span) -> Result { + match k { + Keyword::Var => { + self.bump()?; + let decls = self.parse_var_decls(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Var(decls), self.span_from(start))) + } + Keyword::If => self.parse_if(), + Keyword::Do => self.parse_do_while(start), + Keyword::While => self.parse_while(start), + Keyword::For => self.parse_for(start), + Keyword::Continue | Keyword::Break => self.parse_jump(k == Keyword::Break, start), + Keyword::Return => self.parse_return(start), + Keyword::With => self.parse_with(start), + Keyword::Switch => self.parse_switch(start), + Keyword::Throw => self.parse_throw(start), + Keyword::Try => self.parse_try(start), + Keyword::Debugger => { + self.bump()?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Debugger, self.span_from(start))) + } + _ => { + let expr = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Expr(expr), self.span_from(start))) + } + } + } + + /// `{ … }`: its statements. + fn parse_block(&mut self) -> Result> { + self.expect(Punct::LBrace)?; + let mut body = Vec::new(); + while !self.at(Punct::RBrace) { + if self.tok.kind == TokenKind::Eof { + return Err(self.unexpected()); + } + body.push(self.parse_statement_item()?); + } + self.bump()?; + Ok(body) + } + + /// `var` declarations after the `var`. `no_in` leaves `in` to a `for (… in …)` around them. + fn parse_var_decls(&mut self, no_in: bool) -> Result> { + let mut decls = Vec::new(); + loop { + let name = self.binding_identifier()?; + let init = if self.eat(Punct::Eq)? { Some(self.parse_assignment(no_in)?) } else { None }; + decls.push(VarDecl { name, init }); + if !self.eat(Punct::Comma)? { + return Ok(decls); + } + } + } + + /// `if (…) … else if (…) … else …`, the `else if` chain read with a loop. + fn parse_if(&mut self) -> Result { + let mut chain = Vec::new(); + let alternate = loop { + let start = self.expect_keyword(Keyword::If)?.span; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let consequent = self.parse_branch()?; + chain.push((start, test, consequent)); + if !self.at_keyword(Keyword::Else) { + break None; + } + self.bump()?; + if !self.at_keyword(Keyword::If) { + break Some(self.parse_branch()?); + } + }; + let mut alternate = alternate; + let mut last = None; + for (start, test, consequent) in chain.into_iter().rev() { + let id = self.ast.push_stmt(Stmt::If { test, consequent, alternate }, self.span_from(start)); + alternate = Some(id); + last = Some(id); + } + last.ok_or_else(|| self.unexpected()) + } + + /// The body of an iteration statement, with `break` and `continue` allowed in it. + fn parse_loop_body(&mut self) -> Result { + self.ctx.loops += 1; + self.ctx.breakables += 1; + let body = self.parse_statement(); + self.ctx.loops -= 1; + self.ctx.breakables -= 1; + body + } + + fn parse_do_while(&mut self, start: Span) -> Result { + self.bump()?; + let body = self.parse_loop_body()?; + self.expect_keyword(Keyword::While)?; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + // A `;` after `do … while (…)` may be left out even on the same line, as ECMA-262 2015 + // section 11.9.1 allows. + self.eat(Punct::Semicolon)?; + Ok(self.ast.push_stmt(Stmt::DoWhile { body, test }, self.span_from(start))) + } + + fn parse_while(&mut self, start: Span) -> Result { + self.bump()?; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::While { test, body }, self.span_from(start))) + } + + fn parse_for(&mut self, start: Span) -> Result { + self.bump()?; + self.expect(Punct::LParen)?; + let init = if self.at(Punct::Semicolon) { + None + } else if self.at_keyword(Keyword::Var) { + self.bump()?; + let mut decls = self.parse_var_decls(true)?; + if decls.len() == 1 && self.at_keyword(Keyword::In) { + let decl = decls.pop().ok_or_else(|| self.unexpected())?; + // The variable of a for-in loop takes an initializer only outside strict mode + // code (ECMA-262 2017 Annex B.3.6). + if self.ctx.strict && decl.init.is_some() { + return Err(self.error_at_token(&self.tok, "a for-in variable cannot have an initializer in strict mode code")); + } + return self.parse_for_in(ForInTarget::Var(decl), start); + } + Some(ForInit::Var(decls)) + } else { + let expr = self.parse_expression(true)?; + if self.at_keyword(Keyword::In) { + self.check_target(expr, "invalid left-hand side in for-in loop")?; + return self.parse_for_in(ForInTarget::Expr(expr), start); + } + Some(ForInit::Expr(expr)) + }; + self.expect(Punct::Semicolon)?; + let test = if self.at(Punct::Semicolon) { None } else { Some(self.parse_expression(false)?) }; + self.expect(Punct::Semicolon)?; + let update = if self.at(Punct::RParen) { None } else { Some(self.parse_expression(false)?) }; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::For { init, test, update, body }, self.span_from(start))) + } + + /// The rest of `for (target in object) body`, from `in`. + fn parse_for_in(&mut self, target: ForInTarget, start: Span) -> Result { + self.expect_keyword(Keyword::In)?; + let object = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body }, self.span_from(start))) + } + + /// `break` or `continue`, with an optional label on the same line, which must name a label + /// around it in the same function (an iteration statement's, for `continue`). + fn parse_jump(&mut self, is_break: bool, start: Span) -> Result { + let keyword = self.bump()?; + let label = match &self.tok.kind { + TokenKind::Ident(name) if !self.tok.newline_before => { + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + match self.ctx.labels.get(&name) { + None => return Err(self.error_at_token(&token, format!("undefined label '{name}'"))), + Some(&is_loop) if !is_break && !is_loop => { + return Err(self.error_at_token(&token, format!("label '{name}' does not name a loop, so continue cannot use it"))); + } + Some(_) => {} + } + Some(Name { name, span: token.span }) + } + _ => None, + }; + if label.is_none() { + if is_break && self.ctx.breakables == 0 { + return Err(self.error_at_token(&keyword, "break outside a loop or switch")); + } + if !is_break && self.ctx.loops == 0 { + return Err(self.error_at_token(&keyword, "continue outside a loop")); + } + } + self.semicolon()?; + let stmt = if is_break { Stmt::Break(label) } else { Stmt::Continue(label) }; + Ok(self.ast.push_stmt(stmt, self.span_from(start))) + } + + fn parse_return(&mut self, start: Span) -> Result { + let keyword = self.bump()?; + if !self.ctx.in_function { + return Err(self.error_at_token(&keyword, "return outside a function")); + } + let ends = self.at(Punct::Semicolon) || self.at(Punct::RBrace) || self.tok.kind == TokenKind::Eof || self.tok.newline_before; + let arg = if ends { None } else { Some(self.parse_expression(false)?) }; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Return(arg), self.span_from(start))) + } + + fn parse_with(&mut self, start: Span) -> Result { + if self.ctx.strict { + return Err(self.error_at_token(&self.tok, "with statements are not allowed in strict mode")); + } + self.bump()?; + self.expect(Punct::LParen)?; + let object = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_statement()?; + Ok(self.ast.push_stmt(Stmt::With { object, body }, self.span_from(start))) + } + + fn parse_switch(&mut self, start: Span) -> Result { + self.bump()?; + self.expect(Punct::LParen)?; + let discriminant = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + self.expect(Punct::LBrace)?; + self.ctx.breakables += 1; + let cases = self.parse_cases(); + self.ctx.breakables -= 1; + let cases = cases?; + Ok(self.ast.push_stmt(Stmt::Switch { discriminant, cases }, self.span_from(start))) + } + + /// A `switch` statement's clauses, up to and with its `}`; at most one `default`. + fn parse_cases(&mut self) -> Result> { + let mut cases = Vec::new(); + let mut has_default = false; + while !self.eat(Punct::RBrace)? { + let case_start = self.tok.span; + let test = if self.at_keyword(Keyword::Case) { + self.bump()?; + Some(self.parse_expression(false)?) + } else if self.at_keyword(Keyword::Default) { + if has_default { + return Err(self.error_at_token(&self.tok, "more than one default clause in switch statement")); + } + has_default = true; + self.bump()?; + None + } else { + return Err(self.unexpected()); + }; + self.expect(Punct::Colon)?; + let mut body = Vec::new(); + while !(self.at_keyword(Keyword::Case) || self.at_keyword(Keyword::Default) || self.at(Punct::RBrace)) { + if self.tok.kind == TokenKind::Eof { + return Err(self.unexpected()); + } + body.push(self.parse_statement_item()?); + } + cases.push(SwitchCase { test, body, span: self.span_from(case_start) }); + } + Ok(cases) + } + + fn parse_throw(&mut self, start: Span) -> Result { + self.bump()?; + if self.tok.newline_before { + return Err(self.error_at_token(&self.tok, "line break after throw")); + } + let arg = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Throw(arg), self.span_from(start))) + } + + fn parse_try(&mut self, start: Span) -> Result { + self.bump()?; + let block = self.parse_block()?; + let handler = if self.at_keyword(Keyword::Catch) { + self.bump()?; + self.expect(Punct::LParen)?; + let param = self.binding_identifier()?; + self.expect(Punct::RParen)?; + Some(CatchClause { param, body: self.parse_block()? }) + } else { + None + }; + let finalizer = if self.at_keyword(Keyword::Finally) { + self.bump()?; + Some(self.parse_block()?) + } else { + None + }; + if handler.is_none() && finalizer.is_none() { + return Err(self.error_at_token(&self.tok, "missing catch or finally after try")); + } + Ok(self.ast.push_stmt(Stmt::Try { block, handler, finalizer }, self.span_from(start))) + } + + /// `a: b: statement`: the labels read with a loop, each unique among the labels around it. + /// `in_list`: the labeled statement stands in a statement list, so outside strict mode code + /// its body may be a function declaration. + fn parse_labeled(&mut self, in_list: bool) -> Result { + let mut labels: Vec<(Name, Span)> = Vec::new(); + let mut chain = HashSet::new(); + while let TokenKind::Ident(name) = &self.tok.kind { + if !self.peek().is_some_and(|t| t.is_punct(Punct::Colon)) { + break; + } + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + if self.ctx.labels.contains_key(&name) || !chain.insert(name.clone()) { + return Err(self.error_at_token(&self.tok, format!("duplicate label '{name}'"))); + } + let token = self.bump()?; + self.bump()?; + labels.push((Name { name, span: token.span }, token.span)); + } + let is_loop = self.at_keyword(Keyword::For) || self.at_keyword(Keyword::While) || self.at_keyword(Keyword::Do); + self.ctx.labels.extend(chain.into_iter().map(|name| (name, is_loop))); + self.function_allowed = in_list && !self.ctx.strict; + let body = self.parse_statement(); + for (label, _) in &labels { + self.ctx.labels.remove(&label.name); + } + let mut body = body?; + for (label, label_start) in labels.into_iter().rev() { + let span = Span::new(label_start.start, self.ast.stmt_span(body).end); + body = self.ast.push_stmt(Stmt::Labeled { label, body }, span); + } + Ok(body) + } + + // ---- functions ---- + + fn parse_function_declaration(&mut self) -> Result { + let start = self.expect_keyword(Keyword::Function)?.span; + let name = self.function_name()?; + let id = self.parse_function_rest(FunctionKind::Declaration, Some(name), start)?; + Ok(self.ast.push_stmt(Stmt::FunctionDecl(id), self.span_from(start))) + } + + /// A function's name, checked as an identifier; the strict-mode checks wait for the body, + /// whose prologue may make the function strict. + fn function_name(&mut self) -> Result<(Name, Token)> { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected a function name but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + Ok((Name { name, span: token.span }, token)) + } + + /// The parameters and body of a function, from `(`. + fn parse_function_rest(&mut self, kind: FunctionKind, name: Option<(Name, Token)>, start: Span) -> Result { + self.enter()?; + let result = self.function_rest(kind, name, start); + self.leave(); + result + } + + fn function_rest(&mut self, kind: FunctionKind, name: Option<(Name, Token)>, start: Span) -> Result { + self.expect(Punct::LParen)?; + let mut params: Vec<(Name, Token)> = Vec::new(); + if !self.at(Punct::RParen) { + loop { + let (param, token) = self.function_name_or_param()?; + params.push((param, token)); + if !self.eat(Punct::Comma)? { + break; + } + } + } + let close = self.expect(Punct::RParen)?; + match kind { + FunctionKind::Getter if !params.is_empty() => return Err(self.error_at_token(&close, "a getter must not have parameters")), + FunctionKind::Setter if params.len() != 1 => return Err(self.error_at_token(&close, "a setter must have exactly one parameter")), + _ => {} + } + self.expect(Punct::LBrace)?; + let inner = Context { strict: self.ctx.strict, in_function: true, ..Context::default() }; + let outer = std::mem::replace(&mut self.ctx, inner); + let body = self.parse_body(true); + let strict = self.ctx.strict; + self.ctx = outer; + let body = body?; + self.expect(Punct::RBrace)?; + if strict { + self.check_strict_function(name.as_ref(), ¶ms)?; + } + Ok(self.ast.push_function(Function { + kind, + name: name.map(|(n, _)| n), + params: params.into_iter().map(|(n, _)| n).collect(), + body, + strict, + span: self.span_from(start), + })) + } + + /// A parameter's name. + fn function_name_or_param(&mut self) -> Result<(Name, Token)> { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected a parameter name but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + Ok((Name { name, span: token.span }, token)) + } + + /// The rules strict mode code sets for a function's name and parameters (Annex C): neither + /// `eval`, `arguments` nor a strict-mode reserved word, and no parameter name twice. + fn check_strict_function(&self, name: Option<&(Name, Token)>, params: &[(Name, Token)]) -> Result<()> { + for (n, token) in name.into_iter().chain(params) { + if is_eval_or_arguments(&n.name) { + return Err(self.error_at_token(token, format!("'{}' cannot be declared in strict mode", n.name))); + } + if strict_reserved(&n.name) { + return Err(self.error_at_token(token, format!("'{}' is a reserved word in strict mode", n.name))); + } + } + let mut seen = HashSet::new(); + for (p, token) in params { + if !seen.insert(p.name.as_str()) { + return Err(self.error_at_token(token, format!("duplicate parameter name '{}' is not allowed in strict mode", p.name))); + } + } + Ok(()) + } + + // ---- expressions ---- + + /// An expression: assignment expressions separated by commas. `no_in` leaves `in` to a + /// `for (… in …)` around it. + fn parse_expression(&mut self, no_in: bool) -> Result { + let first = self.parse_assignment(no_in)?; + if !self.at(Punct::Comma) { + return Ok(first); + } + let mut list = vec![first]; + while self.eat(Punct::Comma)? { + list.push(self.parse_assignment(no_in)?); + } + let span = self.ast.expr_span(first).to(Span::new(self.prev_end, self.prev_end)); + Ok(self.ast.push_expr(Expr::Sequence(list), span)) + } + + fn parse_assignment(&mut self, no_in: bool) -> Result { + self.enter()?; + let result = self.assignment(no_in); + self.leave(); + result + } + + /// `a = b += c`: the targets kept on a stack, so a long chain does not nest. + fn assignment(&mut self, no_in: bool) -> Result { + let mut targets: Vec<(ExprId, AssignOp)> = Vec::new(); + loop { + let expr = self.parse_conditional(no_in)?; + let Some(op) = assign_op(&self.tok) else { + let mut value = expr; + while let Some((target, op)) = targets.pop() { + let span = self.ast.expr_span(target).to(self.ast.expr_span(value)); + value = self.ast.push_expr(Expr::Assign { op, target, value }, span); + } + return Ok(value); + }; + self.check_target(expr, "invalid assignment target")?; + self.bump()?; + targets.push((expr, op)); + } + } + + /// 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). + fn check_target(&self, expr: ExprId, message: &str) -> Result<()> { + let inner = self.ast.unparenthesized(expr); + match self.ast.expr(inner) { + Some(Expr::Ident(name)) => { + if self.ctx.strict && is_eval_or_arguments(name) { + return Err(self.error_at_offset(self.ast.expr_span(inner).start, format!("cannot assign to '{name}' in strict mode"))); + } + Ok(()) + } + Some(Expr::Member { .. } | Expr::Index { .. }) => Ok(()), + Some(Expr::Call { .. }) if !self.ctx.strict => Ok(()), + _ => Err(self.error_at_offset(self.ast.expr_span(expr).start, message)), + } + } + + fn parse_conditional(&mut self, no_in: bool) -> Result { + let test = self.parse_binary(no_in)?; + if !self.at(Punct::Question) { + return Ok(test); + } + self.bump()?; + let consequent = self.parse_assignment(false)?; + self.expect(Punct::Colon)?; + let alternate = self.parse_assignment(no_in)?; + let span = self.ast.expr_span(test).to(self.ast.expr_span(alternate)); + Ok(self.ast.push_expr(Expr::Conditional { test, consequent, alternate }, span)) + } + + /// Binary and logical operators, read with an operator stack by precedence (all are left + /// associative). + fn parse_binary(&mut self, no_in: bool) -> Result { + let mut operands = vec![self.parse_unary()?]; + let mut ops: Vec<(BinKind, u8)> = Vec::new(); + while let Some((op, precedence)) = binary_op(&self.tok, no_in) { + while ops.last().is_some_and(|&(_, top)| top >= precedence) { + self.reduce(&mut operands, &mut ops); + } + self.bump()?; + ops.push((op, precedence)); + operands.push(self.parse_unary()?); + } + while !ops.is_empty() { + self.reduce(&mut operands, &mut ops); + } + operands.pop().ok_or_else(|| self.unexpected()) + } + + /// Joins the top two operands with the top operator. + fn reduce(&mut self, operands: &mut Vec, ops: &mut Vec<(BinKind, u8)>) { + let (Some((op, _)), Some(right), Some(left)) = (ops.pop(), operands.pop(), operands.pop()) else { + return; + }; + let span = self.ast.expr_span(left).to(self.ast.expr_span(right)); + let expr = match op { + BinKind::Binary(op) => Expr::Binary { op, left, right }, + BinKind::Logical(op) => Expr::Logical { op, left, right }, + }; + operands.push(self.ast.push_expr(expr, span)); + } + + /// Prefix operators, read with a loop and applied innermost first. + fn parse_unary(&mut self) -> Result { + let mut prefixes = Vec::new(); + while let Some(prefix) = prefix_op(&self.tok) { + prefixes.push((prefix, self.tok.span)); + self.bump()?; + } + let mut expr = self.parse_postfix()?; + for (prefix, start) in prefixes.into_iter().rev() { + let span = start.to(self.ast.expr_span(expr)); + expr = match prefix { + Prefix::Update(op) => { + self.check_target(expr, "invalid increment or decrement operand")?; + self.ast.push_expr(Expr::Update { op, prefix: true, target: expr }, span) + } + Prefix::Unary(op) => { + if op == UnaryOp::Delete && self.ctx.strict && matches!(self.ast.expr(self.ast.unparenthesized(expr)), Some(Expr::Ident(_))) { + return Err(self.error_at_offset(start.start, "delete of an unqualified identifier in strict mode")); + } + self.ast.push_expr(Expr::Unary { op, arg: expr }, span) + } + }; + } + Ok(expr) + } + + fn parse_postfix(&mut self) -> Result { + let expr = self.parse_left_hand_side()?; + let op = match &self.tok.kind { + TokenKind::Punct(Punct::PlusPlus) if !self.tok.newline_before => UpdateOp::Increment, + TokenKind::Punct(Punct::MinusMinus) if !self.tok.newline_before => UpdateOp::Decrement, + _ => return Ok(expr), + }; + self.check_target(expr, "invalid increment or decrement operand")?; + let token = self.bump()?; + let span = self.ast.expr_span(expr).to(token.span); + Ok(self.ast.push_expr(Expr::Update { op, prefix: false, target: expr }, span)) + } + + /// Member accesses, calls and `new` (section 11.2): each `new` takes the first argument list + /// after its callee, and a `new` left without one constructs with no arguments. + fn parse_left_hand_side(&mut self) -> Result { + let mut news = Vec::new(); + while self.at_keyword(Keyword::New) { + news.push(self.bump()?.span); + } + let mut expr = self.parse_primary()?; + loop { + match &self.tok.kind { + TokenKind::Punct(Punct::Dot) => { + self.bump()?; + let Some(name) = self.tok.identifier_name().map(str::to_string) else { + return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); + }; + let token = self.bump()?; + let span = self.ast.expr_span(expr).to(token.span); + expr = self.ast.push_expr(Expr::Member { object: expr, property: Name { name, span: token.span } }, span); + } + TokenKind::Punct(Punct::LBracket) => { + self.bump()?; + let index = self.parse_expression(false)?; + let close = self.expect(Punct::RBracket)?; + let span = self.ast.expr_span(expr).to(close.span); + expr = self.ast.push_expr(Expr::Index { object: expr, index }, span); + } + TokenKind::Punct(Punct::LParen) => { + let args = self.parse_arguments()?; + let span = Span::new(self.ast.expr_span(expr).start, self.prev_end); + expr = match news.pop() { + Some(new) => self.ast.push_expr(Expr::New { callee: expr, args }, Span::new(new.start, self.prev_end)), + None => self.ast.push_expr(Expr::Call { callee: expr, args }, span), + }; + } + _ => break, + } + } + while let Some(new) = news.pop() { + let span = Span::new(new.start, self.ast.expr_span(expr).end); + expr = self.ast.push_expr(Expr::New { callee: expr, args: Vec::new() }, span); + } + Ok(expr) + } + + fn parse_arguments(&mut self) -> Result> { + self.expect(Punct::LParen)?; + let mut args = Vec::new(); + if !self.at(Punct::RParen) { + loop { + args.push(self.parse_assignment(false)?); + if !self.eat(Punct::Comma)? { + break; + } + } + } + self.expect(Punct::RParen)?; + Ok(args) + } + + fn parse_primary(&mut self) -> Result { + self.enter()?; + let result = self.primary(); + self.leave(); + result + } + + fn primary(&mut self) -> Result { + let start = self.tok.span; + let expr = match &self.tok.kind { + TokenKind::Keyword(Keyword::This) => Expr::This, + TokenKind::Keyword(Keyword::Null) => Expr::Null, + TokenKind::Keyword(Keyword::True) => Expr::Bool(true), + TokenKind::Keyword(Keyword::False) => Expr::Bool(false), + TokenKind::Keyword(Keyword::Function) => return self.parse_function_expression(), + TokenKind::Ident(name) => { + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + Expr::Ident(name) + } + TokenKind::Number(n) => { + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal literals are not allowed in strict mode")); + } + Expr::Number(*n) + } + TokenKind::String(s) => { + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal escape sequences are not allowed in strict mode")); + } + Expr::String(s.clone()) + } + TokenKind::Punct(Punct::Slash | Punct::SlashEq) => { + let regexp = self.lex.rescan_regexp(&self.tok)?; + self.tok = regexp; + let TokenKind::RegExp { pattern, flags } = &self.tok.kind else { + return Err(self.unexpected()); + }; + Expr::RegExp { pattern: pattern.clone(), flags: flags.clone() } + } + TokenKind::Punct(Punct::LBracket) => return self.parse_array(), + TokenKind::Punct(Punct::LBrace) => return self.parse_object(), + TokenKind::Punct(Punct::LParen) => { + self.bump()?; + let inner = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + return Ok(self.ast.push_expr(Expr::Paren(inner), self.span_from(start))); + } + _ => return Err(self.unexpected()), + }; + let token = self.bump()?; + Ok(self.ast.push_expr(expr, token.span)) + } + + fn parse_function_expression(&mut self) -> Result { + let start = self.expect_keyword(Keyword::Function)?.span; + let name = if matches!(self.tok.kind, TokenKind::Ident(_)) { Some(self.function_name()?) } else { None }; + let id = self.parse_function_rest(FunctionKind::Expression, name, start)?; + Ok(self.ast.push_expr(Expr::Function(id), self.span_from(start))) + } + + /// `[a, , b]`: elements, a comma with nothing before it leaving a hole. + fn parse_array(&mut self) -> Result { + let start = self.expect(Punct::LBracket)?.span; + let mut elements = Vec::new(); + loop { + if self.eat(Punct::RBracket)? { + break; + } + if self.eat(Punct::Comma)? { + elements.push(None); + continue; + } + elements.push(Some(self.parse_assignment(false)?)); + if !self.at(Punct::RBracket) { + self.expect(Punct::Comma)?; + } + } + Ok(self.ast.push_expr(Expr::Array(elements), self.span_from(start))) + } + + /// `{a: 1, "b": 2, 3: c, get d() {…}, set d(v) {…}}`. A name may repeat, a later definition + /// replacing an earlier one (ECMA-262 2015 section 12.2.6.1). + fn parse_object(&mut self) -> Result { + let start = self.expect(Punct::LBrace)?.span; + let mut properties = Vec::new(); + while !self.eat(Punct::RBrace)? { + let property = self.parse_property()?; + properties.push(property); + if !self.at(Punct::RBrace) { + self.expect(Punct::Comma)?; + } + } + Ok(self.ast.push_expr(Expr::Object(properties), self.span_from(start))) + } + + fn parse_property(&mut self) -> Result { + let start = self.tok.span; + // `get` and `set` start an accessor only when written without escapes. + let accessor = match self.tok.ident().filter(|_| !self.tok.escaped) { + Some("get") => Some(FunctionKind::Getter), + Some("set") => Some(FunctionKind::Setter), + _ => None, + }; + if let Some(kind) = accessor { + let word = self.bump()?; + if property_key(&self.tok).is_some() { + let (key, _) = self.parse_property_key()?; + let function = self.parse_function_rest(kind, None, start)?; + let value = if kind == FunctionKind::Getter { PropertyValue::Get(function) } else { PropertyValue::Set(function) }; + return Ok(Property { key, value, span: self.span_from(start) }); + } + let key = PropertyKey::Ident(word.ident().unwrap_or_default().to_string()); + return self.property_value(key, start); + } + let (key, _) = self.parse_property_key()?; + self.property_value(key, start) + } + + /// `: value` after a data property's name. + fn property_value(&mut self, key: PropertyKey, start: Span) -> Result { + self.expect(Punct::Colon)?; + let value = self.parse_assignment(false)?; + Ok(Property { key, value: PropertyValue::Init(value), span: self.span_from(start) }) + } + + fn parse_property_key(&mut self) -> Result<(PropertyKey, Token)> { + let Some(key) = property_key(&self.tok) else { + return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); + }; + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal literals and escape sequences are not allowed in strict mode")); + } + let token = self.bump()?; + Ok((key, token)) + } +} + +/// The key a token gives as a property name: an identifier name, a string or a number. +fn property_key(token: &Token) -> Option { + match &token.kind { + TokenKind::String(s) => Some(PropertyKey::String(s.clone())), + TokenKind::Number(n) => Some(PropertyKey::Number(*n)), + _ => token.identifier_name().map(|n| PropertyKey::Ident(n.to_string())), + } +} + +fn is_eval_or_arguments(name: &str) -> bool { + name == "eval" || name == "arguments" +} + +fn assign_op(token: &Token) -> Option { + let TokenKind::Punct(p) = &token.kind else { return None }; + Some(match p { + Punct::Eq => None, + Punct::PlusEq => Some(BinaryOp::Add), + Punct::MinusEq => Some(BinaryOp::Sub), + Punct::StarEq => Some(BinaryOp::Mul), + Punct::SlashEq => Some(BinaryOp::Div), + Punct::PercentEq => Some(BinaryOp::Mod), + Punct::ShlEq => Some(BinaryOp::Shl), + Punct::ShrEq => Some(BinaryOp::Shr), + Punct::UShrEq => Some(BinaryOp::UShr), + Punct::AmpEq => Some(BinaryOp::BitAnd), + Punct::PipeEq => Some(BinaryOp::BitOr), + Punct::CaretEq => Some(BinaryOp::BitXor), + _ => return None, + }) +} + +/// A binary operator and its precedence (higher binds tighter); `in` is left out under `no_in`. +fn binary_op(token: &Token, no_in: bool) -> Option<(BinKind, u8)> { + use BinKind::{Binary as B, Logical as L}; + match &token.kind { + TokenKind::Keyword(Keyword::Instanceof) => Some((B(BinaryOp::Instanceof), 7)), + TokenKind::Keyword(Keyword::In) if !no_in => Some((B(BinaryOp::In), 7)), + TokenKind::Punct(p) => Some(match p { + Punct::PipePipe => (L(LogicalOp::Or), 1), + Punct::AmpAmp => (L(LogicalOp::And), 2), + Punct::Pipe => (B(BinaryOp::BitOr), 3), + Punct::Caret => (B(BinaryOp::BitXor), 4), + Punct::Amp => (B(BinaryOp::BitAnd), 5), + Punct::EqEq => (B(BinaryOp::Eq), 6), + Punct::NotEq => (B(BinaryOp::NotEq), 6), + Punct::EqEqEq => (B(BinaryOp::StrictEq), 6), + Punct::NotEqEq => (B(BinaryOp::StrictNotEq), 6), + Punct::Lt => (B(BinaryOp::Lt), 7), + Punct::Gt => (B(BinaryOp::Gt), 7), + Punct::LtEq => (B(BinaryOp::LtEq), 7), + Punct::GtEq => (B(BinaryOp::GtEq), 7), + Punct::Shl => (B(BinaryOp::Shl), 8), + Punct::Shr => (B(BinaryOp::Shr), 8), + Punct::UShr => (B(BinaryOp::UShr), 8), + Punct::Plus => (B(BinaryOp::Add), 9), + Punct::Minus => (B(BinaryOp::Sub), 9), + Punct::Star => (B(BinaryOp::Mul), 10), + Punct::Slash => (B(BinaryOp::Div), 10), + Punct::Percent => (B(BinaryOp::Mod), 10), + _ => return None, + }), + _ => None, + } +} + +fn prefix_op(token: &Token) -> Option { + Some(match &token.kind { + TokenKind::Keyword(Keyword::Delete) => Prefix::Unary(UnaryOp::Delete), + TokenKind::Keyword(Keyword::Void) => Prefix::Unary(UnaryOp::Void), + TokenKind::Keyword(Keyword::Typeof) => Prefix::Unary(UnaryOp::Typeof), + TokenKind::Punct(Punct::Plus) => Prefix::Unary(UnaryOp::Plus), + TokenKind::Punct(Punct::Minus) => Prefix::Unary(UnaryOp::Minus), + TokenKind::Punct(Punct::Tilde) => Prefix::Unary(UnaryOp::BitNot), + TokenKind::Punct(Punct::Bang) => Prefix::Unary(UnaryOp::Not), + TokenKind::Punct(Punct::PlusPlus) => Prefix::Update(UpdateOp::Increment), + TokenKind::Punct(Punct::MinusMinus) => Prefix::Update(UpdateOp::Decrement), + _ => return None, + }) +} + +/// A token as an error message names it. +fn describe(token: &Token) -> String { + match &token.kind { + TokenKind::Eof => "end of input".into(), + TokenKind::Ident(name) => format!("identifier '{name}'"), + TokenKind::Keyword(k) => format!("token '{}'", k.as_str()), + TokenKind::Punct(p) => format!("token '{}'", p.as_str()), + TokenKind::Number(_) => "number".into(), + TokenKind::String(_) => "string".into(), + TokenKind::RegExp { .. } => "regular expression".into(), + TokenKind::Directive(_) => "preprocessor line".into(), + } +} diff --git a/crates/js/src/regexp.rs b/crates/js/src/regexp.rs new file mode 100644 index 000000000..cda4f5307 --- /dev/null +++ b/crates/js/src/regexp.rs @@ -0,0 +1,191 @@ +//! The early check of a regular expression literal's pattern (ECMA-262 5.1 section 15.10.1, with +//! the lenient forms of Annex B that scripts rely on: a `]`, `{` or `}` that starts no class or +//! quantifier is a literal character, a lookahead may take a quantifier, and a class range with a +//! class escape at one end is a set of literal characters). +//! +//! The check reads the pattern once, front to back, counting open groups instead of recursing. + +/// Checks `pattern`, the text between a literal's slashes. Groups may nest `max_depth` deep. +pub fn check(pattern: &[u16], max_depth: usize) -> Result<(), String> { + let mut i = 0; + let mut depth = 0usize; + // The term before the current position may take a quantifier. + let mut quantifiable = false; + while let Some(&unit) = pattern.get(i) { + let c = char::from_u32(u32::from(unit)).unwrap_or('\u{fffd}'); + match c { + '\\' => { + let next = pattern.get(i + 1).ok_or("\\ at end of pattern")?; + quantifiable = !matches!(char::from_u32(u32::from(*next)), Some('b' | 'B')); + i += 2; + } + '(' => { + if pattern.get(i + 1) == Some(&u16::from(b'?')) { + match pattern.get(i + 2).and_then(|&u| char::from_u32(u32::from(u))) { + Some(':' | '=' | '!') => i += 3, + _ => return Err("invalid group".into()), + } + } else { + i += 1; + } + depth += 1; + if depth > max_depth { + return Err(crate::error::TOO_DEEP.into()); + } + quantifiable = false; + } + ')' => { + depth = depth.checked_sub(1).ok_or("unmatched ')'")?; + quantifiable = true; + i += 1; + } + '[' => { + i = class(pattern, i + 1)?; + quantifiable = true; + } + '*' | '+' | '?' => { + if !quantifiable { + return Err("nothing to repeat".into()); + } + i = lazy(pattern, i + 1); + quantifiable = false; + } + '{' => match braces(pattern, i + 1) { + Some((min, max, end)) => { + if !quantifiable { + return Err("nothing to repeat".into()); + } + if max.is_some_and(|max| max < min) { + return Err("numbers out of order in {} quantifier".into()); + } + i = lazy(pattern, end); + quantifiable = false; + } + None => { + quantifiable = true; + i += 1; + } + }, + '^' | '$' | '|' => { + quantifiable = false; + i += 1; + } + _ => { + quantifiable = true; + i += 1; + } + } + } + if depth > 0 { + return Err("unterminated group".into()); + } + Ok(()) +} + +/// After a quantifier ending before `i`: past the `?` that makes it lazy, if there is one. +fn lazy(pattern: &[u16], i: usize) -> usize { + if pattern.get(i) == Some(&u16::from(b'?')) { i + 1 } else { i } +} + +/// A `{n}`, `{n,}` or `{n,m}` quantifier whose `{` is before `i`: its bounds and the position +/// after its `}`. `None` when the text there is not one. +fn braces(pattern: &[u16], i: usize) -> Option<(u64, Option, usize)> { + let (min, mut i) = digits(pattern, i)?; + let max = if pattern.get(i) == Some(&u16::from(b',')) { + i += 1; + match digits(pattern, i) { + Some((max, end)) => { + i = end; + Some(max) + } + None => None, + } + } else { + Some(min) + }; + (pattern.get(i) == Some(&u16::from(b'}'))).then_some((min, max, i + 1)) +} + +/// Decimal digits at `i`: their value (saturated) and the position after them. +fn digits(pattern: &[u16], mut i: usize) -> Option<(u64, usize)> { + let start = i; + let mut value: u64 = 0; + while let Some(d) = pattern.get(i).and_then(|&u| char::from_u32(u32::from(u))).and_then(|c| c.to_digit(10)) { + value = value.saturating_mul(10).saturating_add(u64::from(d)); + i += 1; + } + (i > start).then_some((value, i)) +} + +/// Reads a character class whose `[` is before `i` and returns the position after its `]`. +fn class(pattern: &[u16], mut i: usize) -> Result { + if pattern.get(i) == Some(&u16::from(b'^')) { + i += 1; + } + // The last class atom's character (`None` for a class escape such as `\d`), when a `-` after it + // may make a range. + let mut prev: Option> = None; + loop { + let unit = *pattern.get(i).ok_or("unterminated character class")?; + if unit == u16::from(b']') { + return Ok(i + 1); + } + let (atom, next) = class_atom(pattern, i)?; + i = next; + let dash = pattern.get(i) == Some(&u16::from(b'-')) && pattern.get(i + 1).is_some_and(|&u| u != u16::from(b']')); + match prev.take() { + Some(low) => { + if let (Some(low), Some(high)) = (low, atom) + && low > high + { + return Err("range out of order in character class".into()); + } + } + None if dash => { + prev = Some(atom); + i += 1; + } + None => {} + } + } +} + +/// A class atom at `i`: its character (`None` for a class escape) and the position after it. +fn class_atom(pattern: &[u16], i: usize) -> Result<(Option, usize), String> { + let unit = u32::from(*pattern.get(i).ok_or("unterminated character class")?); + if unit != u32::from(b'\\') { + return Ok((Some(unit), i + 1)); + } + let next = u32::from(*pattern.get(i + 1).ok_or("\\ at end of pattern")?); + let c = char::from_u32(next).unwrap_or('\u{fffd}'); + let hex = |n: usize| -> Option { + (0..n).try_fold(0, |v, k| pattern.get(i + 2 + k).and_then(|&u| crate::chars::hex_value(u32::from(u))).map(|d| v * 16 + d)) + }; + Ok(match c { + 'd' | 'D' | 's' | 'S' | 'w' | 'W' => (None, i + 2), + 'b' => (Some(0x08), i + 2), + 'f' => (Some(0x0C), i + 2), + 'n' => (Some(0x0A), i + 2), + 'r' => (Some(0x0D), i + 2), + 't' => (Some(0x09), i + 2), + 'v' => (Some(0x0B), i + 2), + 'x' => hex(2).map_or((Some(next), i + 2), |v| (Some(v), i + 4)), + 'u' => hex(4).map_or((Some(next), i + 2), |v| (Some(v), i + 6)), + 'c' => match pattern.get(i + 2).map(|&u| u32::from(u)) { + Some(l) if char::from_u32(l).is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_') => (Some(l % 32), i + 3), + _ => (Some(u32::from(b'\\')), i + 1), + }, + '0'..='7' => { + let mut value = 0; + let mut j = i + 1; + while j < i + 4 + && let Some(d) = pattern.get(j).and_then(|&u| char::from_u32(u32::from(u))).and_then(|ch| ch.to_digit(8)) + { + value = value * 8 + d; + j += 1; + } + (Some(value), j) + } + _ => (Some(next), i + 2), + }) +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs new file mode 100644 index 000000000..fb357e1bf --- /dev/null +++ b/crates/js/src/tests.rs @@ -0,0 +1,804 @@ +use proptest::prelude::*; + +use crate::{ + BinaryOp, Expr, ExprId, ForInTarget, FunctionKind, Options, Program, PropertyValue, Stmt, StmtId, SyntaxError, TOO_DEEP, TokenKind, parse, + tokenize, +}; + +fn ok(src: &str) -> Program { + match parse(src, &Options::default()) { + Ok(p) => p, + Err(e) => panic!("{src:?} should parse: {e}"), + } +} + +fn err(src: &str) -> SyntaxError { + match parse(src, &Options::default()) { + Ok(_) => panic!("{src:?} should not parse"), + Err(e) => e, + } +} + +fn strict_err(src: &str) -> SyntaxError { + let full = format!("'use strict';\n{src}"); + match parse(&full, &Options::default()) { + Ok(_) => panic!("{src:?} should not parse in strict mode code"), + Err(e) => e, + } +} + +fn with_preprocessor() -> Options { + Options { preprocessor_lines: true, ..Options::default() } +} + +/// The expression of the program's only (or first) statement. +fn first_expr(p: &Program) -> ExprId { + match p.ast.stmt(p.body[0]) { + Some(Stmt::Expr(e)) => *e, + other => panic!("not an expression statement: {other:?}"), + } +} + +fn expr(p: &Program, id: ExprId) -> &Expr { + p.ast.expr(id).unwrap() +} + +fn stmt(p: &Program, id: StmtId) -> &Stmt { + p.ast.stmt(id).unwrap() +} + +fn utf16(s: &str) -> Vec { + s.encode_utf16().collect() +} + +// ---- expressions ---- + +#[test] +fn binary_operators_follow_precedence_and_associate_left() { + let p = ok("a + b * c - d;"); + let Expr::Binary { op: BinaryOp::Sub, left, right } = expr(&p, first_expr(&p)) else { panic!() }; + assert!(matches!(expr(&p, *right), Expr::Ident(n) if n == "d")); + let Expr::Binary { op: BinaryOp::Add, right: mul, .. } = expr(&p, *left) else { panic!() }; + assert!(matches!(expr(&p, *mul), Expr::Binary { op: BinaryOp::Mul, .. })); +} + +#[test] +fn logical_operators_bind_looser_than_comparisons() { + let p = ok("a || b && c == d;"); + let Expr::Logical { right, .. } = expr(&p, first_expr(&p)) else { panic!() }; + let Expr::Logical { right: eq, .. } = expr(&p, *right) else { panic!() }; + assert!(matches!(expr(&p, *eq), Expr::Binary { op: BinaryOp::Eq, .. })); +} + +#[test] +fn assignment_associates_right() { + let p = ok("a = b += c;"); + let Expr::Assign { op: None, value, .. } = expr(&p, first_expr(&p)) else { panic!() }; + assert!(matches!(expr(&p, *value), Expr::Assign { op: Some(BinaryOp::Add), .. })); +} + +#[test] +fn new_takes_the_first_argument_list_after_its_callee() { + let p = ok("new a.b(1).c(2);"); + let Expr::Call { callee, args } = expr(&p, first_expr(&p)) else { panic!() }; + assert_eq!(args.len(), 1); + let Expr::Member { object, property } = expr(&p, *callee) else { panic!() }; + assert_eq!(property.name, "c"); + let Expr::New { callee, args } = expr(&p, *object) else { panic!() }; + assert_eq!(args.len(), 1); + assert!(matches!(expr(&p, *callee), Expr::Member { .. })); + + let p = ok("new new X()();"); + let Expr::New { callee, .. } = expr(&p, first_expr(&p)) else { panic!() }; + assert!(matches!(expr(&p, *callee), Expr::New { .. })); + + let p = ok("new X;"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::New { args, .. } if args.is_empty())); +} + +#[test] +fn conditional_and_sequence_expressions() { + let p = ok("a ? b : c ? d : e, f;"); + let Expr::Sequence(list) = expr(&p, first_expr(&p)) else { panic!() }; + assert_eq!(list.len(), 2); + let Expr::Conditional { alternate, .. } = expr(&p, list[0]) else { panic!() }; + assert!(matches!(expr(&p, *alternate), Expr::Conditional { .. })); +} + +#[test] +fn array_literals_keep_holes() { + let p = ok("[1, , 3, ];"); + let Expr::Array(elements) = expr(&p, first_expr(&p)) else { panic!() }; + assert_eq!(elements.len(), 3); + assert!(elements[1].is_none()); + let p = ok("[,];"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Array(e) if e.len() == 1)); +} + +#[test] +fn object_literals_with_accessors_and_keyword_names() { + let p = ok("({a: 1, 'b': 2, 3: 3, if: 4, get: 5, set: 6, get x() { return 1; }, set x(v) {}, });"); + let Expr::Paren(inner) = expr(&p, first_expr(&p)) else { panic!() }; + let Expr::Object(props) = expr(&p, *inner) else { panic!() }; + assert_eq!(props.len(), 8); + assert!(matches!(props[6].value, PropertyValue::Get(_))); + assert!(matches!(props[7].value, PropertyValue::Set(_))); + let PropertyValue::Get(getter) = props[6].value else { panic!() }; + assert_eq!(p.ast.function(getter).unwrap().kind, FunctionKind::Getter); +} + +#[test] +fn accessors_take_the_right_number_of_parameters() { + assert!(err("({get a(x) {}});").message.contains("getter")); + assert!(err("({set a() {}});").message.contains("setter")); + assert!(err("({set a(x, y) {}});").message.contains("setter")); +} + +#[test] +fn property_names_may_repeat() { + for src in ["({a: 1, a: 2});", "({a: 1, 'a': 2});", "({get a() {}, set a(v) {}});", "({a: 1, get a() {}});", "({get a() {}, get a() {}});"] { + ok(src); + ok(&format!("'use strict'; {src}")); + } +} + +#[test] +fn escaped_get_and_set_do_not_start_accessors() { + assert!(err("({g\\u0065t a() {}});").message.contains("expected ':'")); + assert!(err("({\\u0073et a(v) {}});").message.contains("expected ':'")); + ok("({g\\u0065t: 1});"); +} + +#[test] +fn member_names_may_be_reserved_words() { + ok("a.if.var.function = b.true;"); + ok("a.\\u0076ar = 1;"); +} + +// ---- statements ---- + +#[test] +fn else_if_chains_nest_as_alternates() { + let p = ok("if (a) x; else if (b) y; else z;"); + let Stmt::If { alternate: Some(alt), .. } = stmt(&p, p.body[0]) else { panic!() }; + let Stmt::If { alternate: Some(last), .. } = stmt(&p, *alt) else { panic!() }; + assert!(matches!(stmt(&p, *last), Stmt::Expr(_))); +} + +#[test] +fn for_statements_of_every_form() { + ok("for (;;) break;"); + ok("for (var i = 0, n = a.length; i < n; i++) {}"); + ok("for (i = 0; i < 10; i++) continue;"); + ok("for (var k in o) {}"); + ok("for (k in o) {}"); + ok("for (a.b in o) {}"); + ok("for (var x = 1 in o) {}"); + ok("for (var i = (a in b); i; ) break;"); + let p = ok("for (var k in o) {}"); + assert!(matches!(stmt(&p, p.body[0]), Stmt::ForIn { target: ForInTarget::Var(_), .. })); + assert!(err("for (a + b in o) {}").message.contains("for-in")); + assert!(err("for (var a, b in o) {}").message.contains("expected ';'")); +} + +#[test] +fn switch_try_with_labels_and_debugger() { + ok("switch (x) { case 1: a(); break; default: b(); case 2: }"); + ok("try { a(); } catch (e) { b(); } finally { c(); }"); + ok("try {} finally {}"); + ok("with (o) { a = 1; }"); + ok("outer: for (;;) { inner: for (;;) { continue outer; } }"); + ok("a: b: while (1) { continue a; }"); + ok("a: { break a; }"); + ok("debugger;"); + ok("do x(); while (a) y();"); + assert!(err("switch (x) { default: default: }").message.contains("default")); + assert!(err("try {}").message.contains("catch or finally")); +} + +#[test] +fn break_and_continue_need_a_target() { + assert!(err("break;").message.contains("break outside")); + assert!(err("continue;").message.contains("continue outside")); + assert!(err("switch (x) { case 1: continue; }").message.contains("continue outside")); + assert!(err("for (;;) { break nowhere; }").message.contains("undefined label")); + assert!(err("a: { continue a; }").message.contains("does not name a loop")); + assert!(err("a: while (1) { function f() { break a; } }").message.contains("undefined label")); + assert!(err("while (1) { function f() { break; } }").message.contains("break outside")); + assert!(err("a: a: ;").message.contains("duplicate label")); + assert!(err("a: { a: ; }").message.contains("duplicate label")); + ok("a: ; a: ;"); +} + +#[test] +fn return_only_inside_functions() { + assert!(err("return 1;").message.contains("return outside")); + ok("function f() { return; }"); + ok("(function () { return 1 })();"); +} + +#[test] +fn function_declarations_in_blocks_and_branches() { + ok("{ function f() {} }"); + ok("if (a) function f() {} else function g() {}"); + ok("switch (a) { case 1: function f() {} }"); + ok("l: function f() {}"); + ok("{ l: m: function f() {} }"); + ok("'use strict'; { function f() {} }"); + assert!(strict_err("if (a) function f() {}").message.contains("functions can only be declared")); + assert!(strict_err("l: function f() {}").message.contains("functions can only be declared")); + for body in ["while (a) function f() {}", "for (;;) function f() {}", "do function f() {} while (a)", "with (a) function f() {}"] { + assert!(err(body).message.contains("cannot stand here"), "{body}"); + } + assert!(err("if (a) l: function f() {}").message.contains("cannot stand here")); + assert!(err("while (a) l: function f() {}").message.contains("cannot stand here")); +} + +// ---- automatic semicolon insertion ---- + +#[test] +fn semicolons_are_inserted_at_line_breaks_braces_and_the_end() { + ok("a = 1\nb = 2"); + ok("function f() { return 1 }"); + ok("a = 1"); + let e = err("a = 1 b = 2"); + assert_eq!((e.line, e.column), (1, 7)); +} + +#[test] +fn restricted_productions_end_at_a_line_break() { + let p = ok("function f() { return\n1 }"); + let Stmt::FunctionDecl(f) = stmt(&p, p.body[0]) else { panic!() }; + let body = &p.ast.function(*f).unwrap().body; + assert_eq!(body.len(), 2); + assert!(matches!(stmt(&p, body[0]), Stmt::Return(None))); + + let p = ok("a\n++b"); + assert_eq!(p.body.len(), 2); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Ident(_))); + + ok("for (;;) { break\nfoo }"); + assert!(err("throw\nnew Error()").message.contains("line break after throw")); +} + +#[test] +fn no_semicolon_is_inserted_inside_a_for_header() { + assert!(err("for (a\nb\n) {}").message.contains("expected ';'")); +} + +// ---- the division and regular expression goals ---- + +#[test] +fn slash_divides_after_a_value_and_starts_a_regexp_elsewhere() { + let p = ok("a / b / c;"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Binary { op: BinaryOp::Div, .. })); + let p = ok("x = /a\\/b[/]c/gi;"); + let Expr::Assign { value, .. } = expr(&p, first_expr(&p)) else { panic!() }; + let Expr::RegExp { pattern, flags } = expr(&p, *value) else { panic!() }; + assert_eq!(*pattern, utf16("a\\/b[/]c")); + assert_eq!(flags, "gi"); + ok("if (x) /re/.test(y);"); + ok("{} /re/.test(y);"); + ok("x = y /= 2;"); + ok("x = /=a/;"); + ok("return_ = typeof /x/;"); + ok("a(/x/, /y/);"); + // After a line break a `/` still divides when it can: there is no semicolon to insert. + let p = ok("a = b\n/c/g"); + assert_eq!(p.body.len(), 1); +} + +#[test] +fn tokenize_reads_each_slash_as_the_grammar_decides() { + let tokens = tokenize("a = b / c; d = /e/g.exec(f);", &Options::default()).unwrap(); + let kinds: Vec<&TokenKind> = tokens.iter().map(|t| &t.kind).collect(); + assert!(kinds.iter().any(|k| matches!(k, TokenKind::RegExp { flags, .. } if flags == "g"))); + assert_eq!(kinds.iter().filter(|k| matches!(k, TokenKind::RegExp { .. })).count(), 1); + assert!(matches!(kinds.last(), Some(TokenKind::Eof))); +} + +// ---- literals ---- + +fn number(src: &str) -> f64 { + let p = ok(src); + let Expr::Number(n) = expr(&p, first_expr(&p)) else { panic!("{src}") }; + *n +} + +fn string(src: &str) -> Vec { + let p = ok(src); + let Expr::String(s) = expr(&p, first_expr(&p)) else { panic!("{src}") }; + s.clone() +} + +#[test] +fn numeric_literals() { + assert_eq!(number("0;"), 0.0); + assert_eq!(number("12.5e-1;"), 1.25); + assert_eq!(number(".5;"), 0.5); + assert_eq!(number("5.;"), 5.0); + assert_eq!(number("1E3;"), 1000.0); + assert_eq!(number("0x1F;"), 31.0); + assert_eq!(number("0XfF;"), 255.0); + assert_eq!(number("0x20000000000001;"), 9007199254740992.0); + assert_eq!(number("017;"), 15.0); + assert_eq!(number("08;"), 8.0); + assert_eq!(number("09.5;"), 9.5); + assert_eq!(number("1e400;"), f64::INFINITY); + assert!(err("3in []").message.contains("immediately after numeric literal")); + assert!(err("0x;").message.contains("hexadecimal")); + assert!(err("1.e").message.contains("immediately after")); + assert!(strict_err("017;").message.contains("octal literals")); + assert!(strict_err("08;").message.contains("octal literals")); + assert!(strict_err("({017: 1});").message.contains("octal")); +} + +#[test] +fn string_literals_and_escapes() { + assert_eq!(string(r#""a\tb\n\\\"\'";"#), utf16("a\tb\n\\\"'")); + assert_eq!(string(r#"'\x41é€';"#), utf16("A\u{e9}\u{20ac}")); + assert_eq!(string("'a\\\nb';"), utf16("ab")); + assert_eq!(string("'a\\\r\nb';"), utf16("ab")); + assert_eq!(string(r"'\0';"), vec![0]); + assert_eq!(string(r"'\101\7\08';"), vec![0x41, 7, 0, u16::from(b'8')]); + assert_eq!(string(r"'\q\8';"), utf16("q8")); + assert_eq!(string("'\u{2028}';"), vec![0x2028]); + assert_eq!(string("'\u{1F600}';"), utf16("\u{1F600}")); + assert!(err("'abc").message.contains("unterminated string")); + assert!(err("'a\nb'").message.contains("unterminated string")); + assert!(err(r"'\x4';").message.contains("hexadecimal escape")); + assert!(err(r"'\u12';").message.contains("Unicode escape")); + assert_eq!(string(r"'\u{41}\u{1F600}\u{0000E9}';"), utf16("A\u{1F600}\u{e9}")); + for bad in [r"'\u{}';", r"'\u{110000}';", r"'\u{41';", r"'\u{4g}';"] { + assert!(err(bad).message.contains("Unicode escape"), "{bad}"); + } + assert!(strict_err(r"'\101';").message.contains("octal escape")); + assert!(strict_err(r"'\8';").message.contains("octal escape")); + ok(r"'use strict'; '\0';"); +} + +#[test] +fn identifiers_with_unicode_and_escapes() { + let p = ok("var caf\\u00e9 = 1, \u{3b1}\u{3b2} = 2, $_x1 = 3, a\u{300} = 4;"); + let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!() }; + let names: Vec<&str> = decls.iter().map(|d| d.name.name.as_str()).collect(); + assert_eq!(names, ["caf\u{e9}", "\u{3b1}\u{3b2}", "$_x1", "a\u{300}"]); + let p = ok("\\u{61}b;"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Ident(n) if n == "ab")); + assert!(err("var \\u0076ar = 1;").message.contains("escaped characters")); + assert!(err("var a\\u0020b;").message.contains("identifier escape")); + assert!(err("var \\x41;").message.contains("escape")); + assert!(err("var a = 1 @ 2;").message.contains("unexpected character '@'")); + assert!(err("var \u{2e2f};").message.contains("unexpected character")); + assert!(err("var a\u{2e2f};").message.contains("unexpected character")); +} + +#[test] +fn identifier_characters_follow_their_unicode_categories() { + // Letters (Lu, Ll, Lt, Lm, Lo, Nl) start an identifier; marks (Mn, Mc), decimal digits (Nd) + // and connector punctuation (Pc) may follow. + for good in [ + "var \u{995}\u{9cd}\u{9b7} = 1;", + "var \u{b95}\u{bcd}\u{b95} = 1;", + "var a\u{9be} = 1;", + "var x\u{966} = 1;", + "var a\u{203f}b = 1;", + "var \u{2b0} = 1;", + "var \u{2160} = 1;", + "var \u{1c5} = 1;", + "var \u{5d0}\u{5b7} = 1;", + ] { + ok(good); + } + // Other numbers (No) are not identifier characters, and a mark cannot start an identifier. + for bad in ["var a\u{b2} = 1;", "var x\u{bd} = 1;", "var \u{9be}a = 1;", "var \u{300}a = 1;", "var \u{966} = 1;"] { + assert!(err(bad).message.contains("unexpected character"), "{bad:?}"); + } + assert!(parse("var \\u09BEa = 1;", &Options::default()).is_err()); + assert!(parse("var a\\u00B2 = 1;", &Options::default()).is_err()); +} + +#[test] +fn comments_and_line_terminators() { + let p = ok("a /* one\ntwo */ b"); + assert_eq!(p.body.len(), 2, "a comment with a line break separates statements"); + assert!(err("a /* one two */ b").message.contains("unexpected identifier 'b'")); + assert!(err("/* open").message.contains("unterminated comment")); + ok("// only a comment"); + ok("a\u{2028}b\u{2029}c\r\nd\re"); + ok("\u{feff}var\u{a0}a\u{3000}=\u{b}1\u{c};"); +} + +// ---- regular expression literals ---- + +#[test] +fn regular_expression_flags_and_patterns_are_checked() { + for good in [ + "/a|b*c+?d{2}e{1,}f{1,3}?/", + "/[a-z\\d-]/", + "/[\\d-z]/", + "/]/", + "/{/", + "/a{,}/", + "/x{a}/", + "/(?:a)(?=b)(?!c)/", + "/(?=a)*/", + "/\\1(a)/", + "/[]/", + "/[^]/", + "/\\cJ\\c/", + "/[\\b-\\n]/", + "/a/gim", + ] { + ok(&format!("x = {good};")); + } + for (bad, message) in [ + ("/(/", "unterminated group"), + ("/a)/", "unmatched"), + ("/?/", "nothing to repeat"), + ("/a**/", "nothing to repeat"), + ("/^*/", "nothing to repeat"), + ("/{1}/", "nothing to repeat"), + ("/a{2,1}/", "out of order"), + ("/[z-a]/", "out of order"), + ("/(?x)/", "invalid group"), + ("/[a/", "unterminated regular expression"), + ("/a\\/", "unterminated regular expression"), + ("/a\n/", "unterminated regular expression"), + ("/a/gg", "flags"), + ("/a/x", "flags"), + ("/a/\\u0067", "flags"), + ] { + let e = err(&format!("x = {bad};")); + assert!(e.message.contains(message), "{bad}: {}", e.message); + assert_eq!((e.line, e.column), (1, 5), "{bad}"); + } +} + +// ---- strict mode ---- + +#[test] +fn use_strict_makes_the_rest_strict_mode_code() { + assert!(ok("'use strict'; a;").strict); + assert!(ok("\"use strict\"\na;").strict); + assert!(!ok("'use\\x20strict'; a;").strict, "a directive with an escape is not \"use strict\""); + assert!(!ok("('use strict'); a;").strict); + assert!(!ok("a; 'use strict';").strict); + assert!(ok("'other'; 'use strict'; a;").strict); + assert!(!ok("'use strict' + 1;").strict); + let p = ok("function f() { 'use strict'; } function g() {}"); + let funcs = &p.ast.functions; + assert!(funcs[0].strict && !funcs[1].strict && !p.strict); + assert!(parse("with (a) {}", &Options { strict: true, ..Options::default() }).is_err()); +} + +#[test] +fn strict_mode_early_errors() { + assert!(strict_err("with (a) {}").message.contains("with statements")); + assert!(strict_err("delete a;").message.contains("delete")); + assert!(strict_err("delete ((a));").message.contains("delete")); + ok("'use strict'; delete a.b; delete a[0];"); + for target in ["eval = 1;", "arguments++;", "--eval;", "arguments += 1;", "(eval) = 1;", "for (eval in o) {}"] { + assert!(strict_err(target).message.contains("cannot assign"), "{target}"); + } + for binding in ["var eval;", "function arguments() {}", "function f(eval) {}", "try {} catch (arguments) {}", "(function eval() {});"] { + assert!(strict_err(binding).message.contains("cannot be declared"), "{binding}"); + } + for word in ["implements", "interface", "let", "package", "private", "protected", "public", "static", "yield"] { + ok(&format!("var {word} = 1;")); + assert!(strict_err(&format!("var {word} = 1;")).message.contains("reserved word"), "{word}"); + assert!(strict_err(&format!("{word};")).message.contains("reserved word"), "{word}"); + } + assert!(strict_err("function f(a, a) {}").message.contains("duplicate parameter")); + ok("function f(a, a) {}"); + let e = strict_err("for (var x = 1 in o) {}"); + assert!(e.message.contains("initializer"), "{}", e.message); + assert_eq!((e.line, e.column), (2, 16)); + ok("'use strict'; for (var x in o) {}"); +} + +#[test] +fn a_function_made_strict_by_its_own_prologue_checks_its_name_and_parameters() { + let e = err("function f(a, a) { 'use strict'; }"); + assert!(e.message.contains("duplicate parameter")); + assert_eq!((e.line, e.column), (1, 15)); + assert!(err("function eval() { 'use strict'; }").message.contains("cannot be declared")); + assert!(err("function f(arguments) { 'use strict'; }").message.contains("cannot be declared")); + assert!(err("function static() { 'use strict'; }").message.contains("reserved word")); + assert!(err("function f() { '\\01'; 'use strict'; }").message.contains("octal escape")); + assert!(err("'\\01'; 'use strict';").message.contains("octal escape")); + assert!(err("function f() { 'use strict'; 010 }").message.contains("octal literals")); + assert!(err("function f() { 'use strict'; var public; }").message.contains("reserved word")); +} + +// ---- assignment targets ---- + +#[test] +fn invalid_assignment_targets() { + for bad in ["1 = 2;", "a + b = c;", "(a, b) = 1;", "a = 1 = 2;", "++1;", "1++;", "--a.b++;", "this = 1;", "'a' += 1;", "(a = 1) = 2;"] { + let e = err(bad); + assert!(e.message.contains("invalid"), "{bad}: {}", e.message); + } + ok("(a) = 1; a.b = 1; a[0] += 2; a.b++; --a[1]; (a.b) = c;"); + // Assigning to a call's result fails at run time outside strict mode code, and is a + // SyntaxError in it. + ok("f() = 1; f()++; for (f() in o) {}"); + for call in ["f() = 1;", "f() += 1;", "++f();", "f()--;", "(f()) = 1;", "for (f() in o) {}"] { + assert!(strict_err(call).message.contains("invalid") || strict_err(call).message.contains("for-in"), "{call}"); + } +} + +// ---- preprocessor lines ---- + +#[test] +fn preprocessor_lines_are_read_as_directives() { + let src = "#target vectorcraft\n #include \"lib/a.jsxinc\"\n//@includepath \"~/x;~/y\"\n#strict on\n#unknown x\nvar a = 1;\n#targetengine main"; + let p = parse(src, &with_preprocessor()).unwrap(); + let names: Vec<(&str, u32, &str)> = p.directives.iter().map(|d| (d.name.as_str(), d.line, d.value.as_str())).collect(); + assert_eq!( + names, + [ + ("target", 1, "vectorcraft"), + ("include", 2, "\"lib/a.jsxinc\""), + ("includepath", 3, "\"~/x;~/y\""), + ("strict", 4, "on"), + ("unknown", 5, "x"), + ("targetengine", 7, "main"), + ] + ); + assert_eq!(p.directives[2].form, "//@"); + assert!(p.directives[4].known.is_none()); + assert_eq!(p.body.len(), 1); + assert!(parse(src, &Options::default()).is_err(), "without preprocessor lines a # is an error"); +} + +#[test] +fn a_hash_away_from_the_start_of_a_line_is_an_error() { + let e = parse("var a = 1; #include x", &with_preprocessor()).unwrap_err(); + assert!(e.message.contains("unexpected character '#'")); + assert_eq!((e.line, e.column), (1, 12)); + assert!(parse("/* c */ #include x", &with_preprocessor()).is_err()); + // A comment naming no directive stays a comment. + assert!(parse("//@other\n", &with_preprocessor()).unwrap().directives.is_empty()); +} + +#[test] +fn a_directive_between_tokens_keeps_its_place_in_the_token_list() { + let tokens = tokenize("a\n#include x\nb", &with_preprocessor()).unwrap(); + let kinds: Vec = tokens + .iter() + .map(|t| match &t.kind { + TokenKind::Ident(n) => n.clone(), + TokenKind::Directive(d) => format!("#{}", d.name), + TokenKind::Eof => "eof".into(), + other => format!("{other:?}"), + }) + .collect(); + assert_eq!(kinds, ["a", "#include", "b", "eof"]); + assert!(tokens[2].newline_before); +} + +// ---- positions ---- + +#[test] +fn errors_carry_line_and_column() { + let e = err("var a = 1;\nvar b = ];"); + assert_eq!((e.line, e.column, e.offset), (2, 9, 19)); + assert_eq!(e.to_string(), "SyntaxError: unexpected token ']' (line 2, column 9)"); + let e = err("a\r\nb\r\n 'x"); + assert_eq!((e.line, e.column), (3, 3)); + let e = err("a\u{2028} )"); + assert_eq!((e.line, e.column), (2, 3)); + let e = err("'\u{1F600}' )"); + assert_eq!((e.line, e.column), (1, 6), "columns count UTF-16 code units"); + let e = err("x = 'a\\\nb' )"); + assert_eq!((e.line, e.column), (2, 4), "a line continuation in a string counts its line"); +} + +#[test] +fn line_count_and_spans() { + let p = ok("a;\r\nb;\nc;"); + assert_eq!(p.line_count(), 3); + assert_eq!(ok("a;\n").line_count(), 1); + assert_eq!(ok("").line_count(), 0); + let span = p.ast.stmt_span(p.body[2]); + assert_eq!(p.line_column(span.start), (3, 1)); + assert_eq!((span.start, span.end), (7, 9)); +} + +// ---- nesting and limits ---- + +#[test] +fn deep_nesting_gives_a_syntax_error_not_a_stack_overflow() { + const N: usize = 100_000; + let cases = [ + format!("{}1{}", "(".repeat(N), ")".repeat(N)), + format!("{}1{}", "[".repeat(N), "]".repeat(N)), + format!("x = {}{}", "{a:".repeat(N), "}".repeat(N)), + format!("{}{}", "{".repeat(N), "}".repeat(N)), + format!("{}{}", "function f(){".repeat(N), "}".repeat(N)), + format!("{}1", "a ? b : ".repeat(N)), + format!("{}x{}", "f(".repeat(N), ")".repeat(N)), + format!("{}x", "if (a) ".repeat(N)), + ]; + for src in &cases { + let e = err(src); + assert_eq!(e.message, TOO_DEEP, "{}", &src[..20]); + assert_eq!(e.line, 1); + assert!(e.column > 1); + } + let e = err(&format!("x = /{}a{}/;", "(".repeat(N), ")".repeat(N))); + assert!(e.message.contains(TOO_DEEP)); +} + +#[test] +fn nesting_up_to_the_limit_parses() { + let depth = 50; + ok(&format!("{}1{};", "(".repeat(depth), ")".repeat(depth))); + ok(&format!("{}{}", "{".repeat(depth), "}".repeat(depth))); + ok(&format!("x = {}{};", "[".repeat(depth), "]".repeat(depth))); +} + +#[test] +fn long_flat_runs_do_not_nest() { + const N: usize = 100_000; + ok(&format!("x = 1{};", " + 1".repeat(N))); + ok(&format!("x = {}y;", "!".repeat(N))); + ok(&format!("{}x = 1;", "a = ".repeat(N))); + ok(&format!("if (a) b;{} else c;", " else if (a) b;".repeat(N))); + ok(&format!("{}x;", (0..N).map(|i| format!("l{i}: ")).collect::())); + ok(&format!("x{};", ".y".repeat(N))); + ok(&format!("x{};", "()".repeat(N))); + ok(&"a;\n".repeat(N)); +} + +#[test] +fn long_label_chains_and_parameter_lists_are_checked_in_linear_time() { + // Each check looks a name up in a hash set or map. Comparing every name with every earlier + // one would take about 2 * 10^10 steps at this size. + const N: usize = 200_000; + let labels: String = (0..N).map(|i| format!("l{i}: ")).collect(); + let body: String = (0..N).map(|i| format!("m{i}: break l{i}; ")).collect(); + ok(&format!("{labels}for (;;) {{ {body}continue l0; }}")); + assert!(err(&format!("{labels}l0: x;")).message.contains("duplicate label")); + assert!(err(&format!("{labels}for (;;) {{ m: break l{N}; }}")).message.contains("undefined label")); + let params = (0..N).map(|i| format!("p{i}")).collect::>().join(", "); + ok(&format!("function f({params}) {{ 'use strict'; }}")); + assert!(err(&format!("function f({params}, p0) {{ 'use strict'; }}")).message.contains("duplicate parameter")); +} + +#[test] +fn nesting_to_the_default_limit_fits_a_small_stack() { + // The deepest nesting each production allows, parsed on a thread with a 1 MiB stack (the + // stack a wasm build or a secondary thread may have). + let n = crate::DEFAULT_MAX_DEPTH; + let cases = [ + format!("x = {}1{};", "(".repeat(n), ")".repeat(n)), + format!("x = {}1{};", "[".repeat(n), "]".repeat(n)), + format!("x = {}1{};", "{a:".repeat(n), "}".repeat(n)), + format!("{}{}", "function f(){".repeat(n), "}".repeat(n)), + format!("{}x;", "if (a) ".repeat(n)), + format!("x = {}1;", "a ? b : ".repeat(n)), + format!("{}x{};", "f(".repeat(n), ")".repeat(n)), + format!("{}x{};", "a[".repeat(n), "]".repeat(n)), + ]; + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || cases.iter().map(|src| parse(src, &Options::default()).map(|_| ()).map_err(|e| e.message)).collect::>()) + .unwrap(); + for result in handle.join().unwrap() { + // Each either parses or stops at the limit; neither overflows the stack. + assert!(result.is_ok() || result == Err(TOO_DEEP.to_string()), "{result:?}"); + } +} + +#[test] +fn sources_over_the_length_limit_are_refused() { + let options = Options { max_source_units: 10, ..Options::default() }; + assert_eq!(parse("a = 1;", &options).map(|_| ()), Ok(())); + assert_eq!(parse("a = 1 + 2 + 3;", &options).unwrap_err().message, crate::TOO_LONG); + assert_eq!(parse("'\u{e9}\u{e9}\u{e9}\u{e9}';", &options).map(|_| ()), Ok(()), "the limit counts UTF-16 code units"); +} + +// ---- robustness ---- + +/// Fragments of the language, put together at random so the parser reaches its deeper states. +const FRAGMENTS: &[&str] = &[ + "var ", + "function ", + "f", + "(", + ")", + "{", + "}", + "[", + "]", + ";", + ",", + "=", + "+", + "-", + "/", + "/=", + "*", + "?", + ":", + "!", + "++", + "--", + "if", + "else", + "for", + "in", + "while", + "do", + "return", + "break", + "continue", + "new", + "delete", + "typeof", + "this", + "with", + "switch", + "case", + "default", + "try", + "catch", + "finally", + "throw", + "get", + "set", + "'s'", + "\"\\x4", + "1", + "0x", + "017", + ".5", + "1e", + "/re/g", + "a:", + "\n", + " ", + "'use strict';", + "#include x\n", + "//c\n", + "/*", + "*/", + "\\u0061", + "\u{2028}", + "eval", + "arguments", + ".", + "x", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn arbitrary_text_never_panics(src in ".{0,200}") { + let _ = parse(&src, &Options::default()); + let _ = parse(&src, &Options { strict: true, preprocessor_lines: true, ..Options::default() }); + } + + #[test] + fn arbitrary_fragments_never_panic(parts in proptest::collection::vec(0..FRAGMENTS.len(), 0..80)) { + let src: String = parts.iter().map(|&i| FRAGMENTS[i]).collect(); + for options in [Options::default(), Options { strict: true, preprocessor_lines: true, max_depth: 8, ..Options::default() }] { + match parse(&src, &options) { + Ok(p) => { + for (_, span) in &p.ast.exprs { + prop_assert!(span.start <= span.end && span.end as usize <= src.encode_utf16().count()); + } + } + Err(e) => prop_assert!(e.line >= 1 && e.column >= 1), + } + } + } + + #[test] + fn arbitrary_utf16_never_panics(units in proptest::collection::vec(any::(), 0..120)) { + let _ = crate::parse_utf16(&units, &Options::default()); + } +} diff --git a/crates/js/src/token.rs b/crates/js/src/token.rs new file mode 100644 index 000000000..53bbb35af --- /dev/null +++ b/crates/js/src/token.rs @@ -0,0 +1,391 @@ +//! Tokens: what the lexer gives the parser. + +/// A range of the source in UTF-16 code units, `start` included and `end` not. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Span { + pub start: u32, + pub end: u32, +} + +impl Span { + pub fn new(start: u32, end: u32) -> Self { + Self { start, end } + } + + /// The span from the start of `self` to the end of `other`. + pub fn to(self, other: Span) -> Span { + Span { start: self.start, end: other.end.max(self.start) } + } +} + +/// The reserved words of ECMA-262 5.1 section 7.6.1 that are reserved in all code, with the +/// literals `null`, `true` and `false`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Keyword { + Break, + Case, + Catch, + Class, + Const, + Continue, + Debugger, + Default, + Delete, + Do, + Else, + Enum, + Export, + Extends, + False, + Finally, + For, + Function, + If, + Import, + In, + Instanceof, + New, + Null, + Return, + Super, + Switch, + This, + Throw, + True, + Try, + Typeof, + Var, + Void, + While, + With, +} + +impl Keyword { + /// The keyword spelled `s`, if `s` is one. + pub fn from_word(s: &str) -> Option { + use Keyword as K; + Some(match s { + "break" => K::Break, + "case" => K::Case, + "catch" => K::Catch, + "class" => K::Class, + "const" => K::Const, + "continue" => K::Continue, + "debugger" => K::Debugger, + "default" => K::Default, + "delete" => K::Delete, + "do" => K::Do, + "else" => K::Else, + "enum" => K::Enum, + "export" => K::Export, + "extends" => K::Extends, + "false" => K::False, + "finally" => K::Finally, + "for" => K::For, + "function" => K::Function, + "if" => K::If, + "import" => K::Import, + "in" => K::In, + "instanceof" => K::Instanceof, + "new" => K::New, + "null" => K::Null, + "return" => K::Return, + "super" => K::Super, + "switch" => K::Switch, + "this" => K::This, + "throw" => K::Throw, + "true" => K::True, + "try" => K::Try, + "typeof" => K::Typeof, + "var" => K::Var, + "void" => K::Void, + "while" => K::While, + "with" => K::With, + _ => return None, + }) + } + + pub fn as_str(self) -> &'static str { + use Keyword as K; + match self { + K::Break => "break", + K::Case => "case", + K::Catch => "catch", + K::Class => "class", + K::Const => "const", + K::Continue => "continue", + K::Debugger => "debugger", + K::Default => "default", + K::Delete => "delete", + K::Do => "do", + K::Else => "else", + K::Enum => "enum", + K::Export => "export", + K::Extends => "extends", + K::False => "false", + K::Finally => "finally", + K::For => "for", + K::Function => "function", + K::If => "if", + K::Import => "import", + K::In => "in", + K::Instanceof => "instanceof", + K::New => "new", + K::Null => "null", + K::Return => "return", + K::Super => "super", + K::Switch => "switch", + K::This => "this", + K::Throw => "throw", + K::True => "true", + K::Try => "try", + K::Typeof => "typeof", + K::Var => "var", + K::Void => "void", + K::While => "while", + K::With => "with", + } + } +} + +/// The words reserved only in strict mode code (ECMA-262 5.1 section 7.6.1.2). +pub fn strict_reserved(name: &str) -> bool { + matches!(name, "implements" | "interface" | "let" | "package" | "private" | "protected" | "public" | "static" | "yield") +} + +/// The punctuators of ECMA-262 5.1 sections 7.7 and 7.8.5 (division). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Punct { + LBrace, + RBrace, + LParen, + RParen, + LBracket, + RBracket, + Dot, + Semicolon, + Comma, + Lt, + Gt, + LtEq, + GtEq, + EqEq, + NotEq, + EqEqEq, + NotEqEq, + Plus, + Minus, + Star, + Percent, + PlusPlus, + MinusMinus, + Shl, + Shr, + UShr, + Amp, + Pipe, + Caret, + Bang, + Tilde, + AmpAmp, + PipePipe, + Question, + Colon, + Eq, + PlusEq, + MinusEq, + StarEq, + PercentEq, + ShlEq, + ShrEq, + UShrEq, + AmpEq, + PipeEq, + CaretEq, + Slash, + SlashEq, +} + +impl Punct { + /// Punctuators by spelling, longest first, so the first that matches is the longest. + pub(crate) const ALL: &'static [(&'static str, Punct)] = &[ + (">>>=", Punct::UShrEq), + ("===", Punct::EqEqEq), + ("!==", Punct::NotEqEq), + (">>>", Punct::UShr), + ("<<=", Punct::ShlEq), + (">>=", Punct::ShrEq), + ("<=", Punct::LtEq), + (">=", Punct::GtEq), + ("==", Punct::EqEq), + ("!=", Punct::NotEq), + ("++", Punct::PlusPlus), + ("--", Punct::MinusMinus), + ("<<", Punct::Shl), + (">>", Punct::Shr), + ("&&", Punct::AmpAmp), + ("||", Punct::PipePipe), + ("+=", Punct::PlusEq), + ("-=", Punct::MinusEq), + ("*=", Punct::StarEq), + ("%=", Punct::PercentEq), + ("&=", Punct::AmpEq), + ("|=", Punct::PipeEq), + ("^=", Punct::CaretEq), + ("/=", Punct::SlashEq), + ("{", Punct::LBrace), + ("}", Punct::RBrace), + ("(", Punct::LParen), + (")", Punct::RParen), + ("[", Punct::LBracket), + ("]", Punct::RBracket), + (".", Punct::Dot), + (";", Punct::Semicolon), + (",", Punct::Comma), + ("<", Punct::Lt), + (">", Punct::Gt), + ("+", Punct::Plus), + ("-", Punct::Minus), + ("*", Punct::Star), + ("%", Punct::Percent), + ("&", Punct::Amp), + ("|", Punct::Pipe), + ("^", Punct::Caret), + ("!", Punct::Bang), + ("~", Punct::Tilde), + ("?", Punct::Question), + (":", Punct::Colon), + ("=", Punct::Eq), + ("/", Punct::Slash), + ]; + + pub fn as_str(self) -> &'static str { + Punct::ALL.iter().find(|(_, p)| *p == self).map_or("", |(s, _)| s) + } +} + +/// A preprocessor line's directive: `#include`, `#includepath`, `#script`, `#strict`, `#target` +/// and `#targetengine`, as the public JavaScript Tools Guide lists them +/// (). +/// Only the names are taken from it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DirectiveName { + Include, + IncludePath, + Script, + Strict, + Target, + TargetEngine, +} + +impl DirectiveName { + /// The directive named `name`, compared without case. + pub fn from_name(name: &str) -> Option { + const NAMES: [(&str, DirectiveName); 6] = [ + ("include", DirectiveName::Include), + ("includepath", DirectiveName::IncludePath), + ("script", DirectiveName::Script), + ("strict", DirectiveName::Strict), + ("target", DirectiveName::Target), + ("targetengine", DirectiveName::TargetEngine), + ]; + NAMES.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, d)| *d) + } + + pub fn as_str(self) -> &'static str { + match self { + DirectiveName::Include => "include", + DirectiveName::IncludePath => "includepath", + DirectiveName::Script => "script", + DirectiveName::Strict => "strict", + DirectiveName::Target => "target", + DirectiveName::TargetEngine => "targetengine", + } + } +} + +/// A preprocessor line: `#name value` at the start of a line, or a `//@name value` comment there +/// that names a directive. It is read and kept, never run. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Directive { + /// The directive, when the name is one of the six (`#name` lines with another name are kept + /// with `None`). + pub known: Option, + /// The name as written. + pub name: String, + /// The text after the name, with blanks around it removed. + pub value: String, + /// `#` or `//@`. + pub form: &'static str, + pub span: Span, + pub line: u32, +} + +/// What a token is. +#[derive(Clone, Debug, PartialEq)] +pub enum TokenKind { + /// The end of the source. + Eof, + /// An identifier, the strict-mode reserved words included. `escaped` tokens held a `\u` escape. + Ident(String), + Keyword(Keyword), + Punct(Punct), + /// A numeric literal's value. + Number(f64), + /// A string literal's value in UTF-16 code units. + String(Vec), + /// A regular expression literal: its pattern and flags as written. + RegExp { + pattern: Vec, + flags: String, + }, + /// A preprocessor line. + Directive(Directive), +} + +/// A token, with where it is and the facts the grammar's rules need about it. +#[derive(Clone, Debug, PartialEq)] +pub struct Token { + pub kind: TokenKind, + pub span: Span, + /// The line (from 1) and column (from 1, in UTF-16 code units) the token starts at. + pub line: u32, + pub column: u32, + /// A line terminator comes between the previous token and this one (automatic semicolon + /// insertion and the restricted productions use it). + pub newline_before: bool, + /// An identifier written with a `\u` escape. + pub escaped: bool, + /// A legacy octal numeric literal (`017`, `08`) or a string with an octal escape (`"\1"`), which + /// strict mode code does not allow. + pub legacy_octal: bool, +} + +impl Token { + pub fn is_punct(&self, p: Punct) -> bool { + self.kind == TokenKind::Punct(p) + } + + pub fn is_keyword(&self, k: Keyword) -> bool { + self.kind == TokenKind::Keyword(k) + } + + /// The identifier's name, for an identifier token. + pub fn ident(&self) -> Option<&str> { + match &self.kind { + TokenKind::Ident(name) => Some(name), + _ => None, + } + } + + /// The name this token gives when it stands where any identifier name may, as a property's + /// name after `.` or in an object literal: identifiers, keywords and the literals `null`, + /// `true` and `false`. + pub fn identifier_name(&self) -> Option<&str> { + match &self.kind { + TokenKind::Ident(name) => Some(name), + TokenKind::Keyword(k) => Some(k.as_str()), + _ => None, + } + } +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs new file mode 100644 index 000000000..c32045507 --- /dev/null +++ b/crates/js/tests/test262.rs @@ -0,0 +1,178 @@ +//! The ES5 parse subset of test262, the ECMAScript conformance suite, when `cargo xtask test262` +//! has fetched it (it sets `VECTORCRAFT_TEST262_DIR`; without it the test looks in +//! `corpus/test262/` and skips when the suite is absent). +//! +//! The subset is every test under `test/language/` that names the ES5 section it was written for +//! (`es5id:`) and has no `features:` tag (the tags name syntax and built-ins later than ES5) and no +//! `module` flag. The other tests without a feature tag (written for later editions, many of them +//! using later syntax) run too, and their counts are printed apart. A test whose metadata says +//! `negative: phase: parse` must give a `SyntaxError`; every other test must parse. Each test runs +//! as sloppy and as strict mode code (with `"use strict";` before it, as the suite's +//! `INTERPRETING.md` says), unless its `onlyStrict`, `noStrict` or `raw` flag says otherwise. The +//! test prints the counts and writes each failure to `failures.txt` beside the suite; it fails only +//! when the suite cannot be read. + +use std::path::{Path, PathBuf}; + +use vectorcraft_js::{Options, parse}; + +/// A test file's metadata, from its `/*--- … ---*/` block. +#[derive(Default)] +struct Meta { + features: Vec, + flags: Vec, + negative_phase: Option, + /// The test names the ES5 section it was written for (`es5id:`). + es5: bool, +} + +fn suite_dir() -> Option { + if let Ok(dir) = std::env::var("VECTORCRAFT_TEST262_DIR") { + return Some(PathBuf::from(dir)); + } + let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/test262"); + let mut dirs: Vec = std::fs::read_dir(corpus).ok()?.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.is_dir()).collect(); + dirs.sort(); + dirs.pop() +} + +/// The `.js` files under `dir`, sorted, with the `_FIXTURE` helper files left out. +fn test_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&d) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "js") && !path.to_string_lossy().contains("_FIXTURE") { + out.push(path); + } + } + } + out.sort(); + out +} + +/// A YAML flow list (`[a, b]`) or the block list (`- a` lines) after a key. +fn list(value: &str, lines: &mut std::iter::Peekable>) -> Vec { + let value = value.trim(); + if let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) { + return inner.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + } + let mut out = Vec::new(); + while let Some(item) = lines.peek().and_then(|l| l.trim().strip_prefix("- ")) { + out.push(item.trim().to_string()); + lines.next(); + } + out +} + +fn meta(source: &str) -> Meta { + let mut meta = Meta::default(); + let Some(block) = source.split_once("/*---").and_then(|(_, rest)| rest.split_once("---*/")).map(|(block, _)| block) else { + return meta; + }; + let mut lines = block.lines().peekable(); + let mut in_negative = false; + while let Some(line) = lines.next() { + let indented = line.starts_with(' '); + let trimmed = line.trim(); + if !indented { + in_negative = false; + } + if let Some(v) = trimmed.strip_prefix("features:").filter(|_| !indented) { + meta.features = list(v, &mut lines); + } else if let Some(v) = trimmed.strip_prefix("flags:").filter(|_| !indented) { + meta.flags = list(v, &mut lines); + } else if trimmed.starts_with("es5id:") && !indented { + meta.es5 = true; + } else if trimmed == "negative:" && !indented { + in_negative = true; + } else if let Some(v) = trimmed.strip_prefix("phase:").filter(|_| in_negative) { + meta.negative_phase = Some(v.trim().to_string()); + } + } + meta +} + +#[test] +fn es5_parse_subset() { + let Some(dir) = suite_dir() else { + println!("test262: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; + let language = dir.join("test").join("language"); + assert!(language.is_dir(), "{} is not a test262 checkout", dir.display()); + let files = test_files(&language); + assert!(!files.is_empty(), "no tests under {}", language.display()); + // VECTORCRAFT_TEST262_VERBOSE=1 prints each test before it is parsed. + let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); + + let (mut positive, mut negative, mut with_features, mut modules) = (0usize, 0usize, 0usize, 0usize); + let (mut passed, mut failed) = (0usize, 0usize); + // The tests without a feature tag that name no ES5 section: files, runs passed and failed. + let (mut later, mut later_passed, mut later_failed) = (0usize, 0usize, 0usize); + let mut failures = String::new(); + let mut later_failures = String::new(); + for path in &files { + let source = String::from_utf8_lossy(&std::fs::read(path).unwrap()).into_owned(); + let meta = meta(&source); + if !meta.features.is_empty() { + with_features += 1; + continue; + } + if meta.flags.iter().any(|f| f == "module") || meta.negative_phase.as_deref() == Some("resolution") { + modules += 1; + continue; + } + let expect_error = meta.negative_phase.as_deref() == Some("parse"); + if !meta.es5 { + later += 1; + } else if expect_error { + negative += 1; + } else { + positive += 1; + } + let has = |flag: &str| meta.flags.iter().any(|f| f == flag); + let modes: &[bool] = if has("raw") || has("noStrict") { + &[false] + } else if has("onlyStrict") { + &[true] + } else { + &[false, true] + }; + for &strict in modes { + if verbose { + eprintln!("{} ({})", path.display(), if strict { "strict" } else { "sloppy" }); + } + let text = if strict { format!("\"use strict\";\n{source}") } else { source.clone() }; + let result = parse(&text, &Options::default()); + let (pass, fail, list) = + if meta.es5 { (&mut passed, &mut failed, &mut failures) } else { (&mut later_passed, &mut later_failed, &mut later_failures) }; + if result.is_err() == expect_error { + *pass += 1; + } else { + *fail += 1; + let rel = path.strip_prefix(&dir).unwrap_or(path).display().to_string().replace('\\', "/"); + let mode = if strict { "strict" } else { "sloppy" }; + let outcome = match &result { + Ok(_) => "parsed; a SyntaxError was expected".to_string(), + Err(e) => e.to_string(), + }; + list.push_str(&format!("{rel} ({mode}): {outcome}\n")); + } + } + } + let report = dir.join("failures.txt"); + std::fs::write(&report, format!("# ES5 subset\n{failures}\n# Other tests without a feature tag\n{later_failures}")).unwrap(); + println!( + "test262 ES5 parse subset ({}): {} tests ({positive} that must parse, {negative} that must give a SyntaxError), \ + {passed} runs passed, {failed} failed. Other tests without a feature tag: {later} tests, {later_passed} runs passed, \ + {later_failed} failed. Skipped {with_features} tests with feature tags and {modules} module tests. Failures: {}", + dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + positive + negative, + report.display() + ); +} diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index eda85d973..7321626cd 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -257,12 +257,60 @@ pub(crate) struct Arrangement { pub(crate) struct Tidy { pub(crate) precision: f64, keep: Vec, + /// `keep` by y, sorted on first use. + keep_by_y: std::sync::OnceLock>, /// Input curves (with inflated bounds) used to rebuild split pieces exactly. curves: Vec<(kurbo::Rect, CubicBez)>, /// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose /// bounds can hold a point, without testing them all. by_x: Vec, max_width: f64, + /// The top edge of their bounds and their index, in order of that edge, with the tallest + /// bounds, made on first use. + by_y: std::sync::OnceLock<(Vec<(f64, usize)>, f64)>, + /// The wide lookups in `curves` and in `keep` so far. + curve_lookups: WideLookups, + point_lookups: WideLookups, +} + +/// The lookups in one list of [`Tidy`] with more than [`WIDE_WINDOW`] candidates along x: the +/// candidates they counted, a score that rises by [`Y_WIN`] when the window along y was the one +/// used and falls by one when it was not (within [`Y_SCORE_LIMIT`] either way), and the lookups +/// made while the score was below zero. +#[derive(Debug, Default)] +struct WideLookups { + scanned: std::sync::atomic::AtomicUsize, + score: std::sync::atomic::AtomicIsize, + below: std::sync::atomic::AtomicUsize, +} + +impl Clone for WideLookups { + fn clone(&self) -> Self { + let get = |n: &std::sync::atomic::AtomicUsize| n.load(std::sync::atomic::Ordering::Relaxed).into(); + Self { scanned: get(&self.scanned), score: self.score().into(), below: get(&self.below) } + } +} + +impl WideLookups { + fn score(&self) -> isize { + self.score.load(std::sync::atomic::Ordering::Relaxed) + } + /// Does a lookup with `window` candidates along x measure the window along y? It does past + /// [`WIDE_WINDOW`] candidates, once wide lookups have scanned [`SORT_COST`] candidates for each + /// of the `len` items that the sort along y orders, while the score is zero or more, and in + /// one of every [`Y_RETRY`] lookups while it is below. + fn search_y(&self, window: usize, len: usize) -> bool { + use std::sync::atomic::Ordering::Relaxed; + window > WIDE_WINDOW + && self.scanned.fetch_add(window, Relaxed) + window >= len.saturating_mul(SORT_COST) + && (self.score() >= 0 || self.below.fetch_add(1, Relaxed) % Y_RETRY == Y_RETRY - 1) + } + /// Counts a lookup that measured along y, and whether that window was the one used. + fn used_y(&self, used: bool) { + let step = if used { Y_WIN } else { -1 }; + let next = (self.score() + step).clamp(-Y_SCORE_LIMIT, Y_SCORE_LIMIT); + self.score.store(next, std::sync::atomic::Ordering::Relaxed); + } } impl Tidy { @@ -288,12 +336,46 @@ impl Tidy { let mut by_x: Vec = (0..curves.len()).collect(); by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0)); let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max); - Self { precision, keep, curves, by_x, max_width } + Self { precision, keep, curves, by_x, max_width, ..Default::default() } } + fn keep_by_y(&self) -> &[Point] { + self.keep_by_y.get_or_init(|| { + let mut keep = self.keep.clone(); + keep.sort_unstable_by(|a, b| a.y.total_cmp(&b.y)); + keep + }) + } + fn by_y(&self) -> (&[(f64, usize)], f64) { + let (by_y, max_height) = self.by_y.get_or_init(|| { + let mut by_y: Vec<(f64, usize)> = self.curves.iter().enumerate().map(|(i, (r, _))| (r.y0, i)).collect(); + by_y.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); + (by_y, self.curves.iter().map(|(r, _)| r.height()).fold(0.0, f64::max)) + }); + (by_y, *max_height) + } + /// Is a kept point within the tolerance of `p`? The points within it along x are tested, or + /// those along y when [`WideLookups::search_y`] holds and there are fewer along y. fn is_kept(&self, p: Point) -> bool { let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); let lo = self.keep.partition_point(|q| q.x < p.x - tol); - self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol) + // Usually one or two points lie within the tolerance along x; count them one by one up to + // the wide window, and search past it. + let rest = &self.keep[lo..]; + let mut within = rest.iter().take(WIDE_WINDOW + 1).take_while(|q| q.x <= p.x + tol).count(); + if within > WIDE_WINDOW { + within = rest.partition_point(|q| q.x <= p.x + tol); + } + let hi = lo + within; + if self.point_lookups.search_y(hi - lo, self.keep.len()) { + let keep_by_y = self.keep_by_y(); + let lo_y = keep_by_y.partition_point(|q| q.y < p.y - tol); + let hi_y = lo_y + keep_by_y[lo_y..].partition_point(|q| q.y <= p.y + tol); + self.point_lookups.used_y(hi_y - lo_y < hi - lo); + if hi_y - lo_y < hi - lo { + return keep_by_y[lo_y..hi_y].iter().any(|q| (q.x - p.x).abs() <= tol); + } + } + self.keep[lo..hi].iter().any(|q| (q.y - p.y).abs() <= tol) } } @@ -609,13 +691,52 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option { None } +/// Past this many candidates along x, a lookup in [`Tidy`] adds them to the count of scanned ones. +const WIDE_WINDOW: usize = 64; + +/// The candidates scanned per item of a list before [`Tidy`] sorts that list along y: about the +/// comparisons per item of sorting 100,000 items. +const SORT_COST: usize = 16; + +/// How much a lookup that used the window along y raises the score in [`WideLookups`]. +const Y_WIN: isize = 4; + +/// The highest and the lowest score in [`WideLookups`] (negated). +const Y_SCORE_LIMIT: isize = 256; + +/// While the score in [`WideLookups`] is below zero, one lookup in this many measures along y. +const Y_RETRY: usize = 32; + impl Tidy { - /// The input curves whose bounds hold both `a` and `b`. + /// The input curves whose bounds hold both `a` and `b`, in the order of `by_x`. The candidates + /// are the curves whose bounds start within the widest bounds' reach of the points along x, or + /// the same along y when [`WideLookups::search_y`] holds and there are under a quarter as many + /// along y. fn curves_around(&self, a: Point, b: Point) -> impl Iterator { + let holds = move |(r, _): &&(kurbo::Rect, CubicBez)| r.contains(a) && r.contains(b); let x = a.x.min(b.x); let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x); let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width); - self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c) + let along_y = self.curve_lookups.search_y(hi - lo, self.curves.len()).then(|| { + let (by_y, max_height) = self.by_y(); + let y = a.y.min(b.y); + let hi_y = by_y.partition_point(|&(y0, _)| y0 <= y); + let lo_y = by_y[..hi_y].partition_point(|&(y0, _)| y0 < y - max_height); + &by_y[lo_y..hi_y] + }); + let along_y = along_y.filter(|ys| { + let used = ys.len().saturating_mul(4) < hi - lo; + self.curve_lookups.used_y(used); + used + }); + let along_y = along_y.map(|ys| { + let mut found: Vec<(usize, &(kurbo::Rect, CubicBez))> = ys.iter().map(|&(_, i)| (i, &self.curves[i])).filter(|(_, c)| holds(c)).collect(); + // `by_x` was sorted stably from index order, so this puts them in its order. + found.sort_by(|(i, p), (j, q)| p.0.x0.total_cmp(&q.0.x0).then(i.cmp(j))); + found.into_iter().map(|(_, c)| c) + }); + let along_x = along_y.is_none().then(|| self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(holds)); + along_x.into_iter().flatten().chain(along_y.into_iter().flatten()).map(|(_, c)| c) } /// `bp` with each curve piece that strays from the one input curve through both its ends @@ -851,6 +972,17 @@ pub fn try_unite_all(paths: &[(&PathData, FillRule)]) -> Result Result { + match crate::sweep_axis::turned_fills(paths) { + Some(turned) => { + let refs: Vec<(&PathData, FillRule)> = turned.iter().map(|(p, r)| (p, *r)).collect(); + try_unite_all_upright(&refs, precision).map(crate::sweep_axis::unturn) + } + None => try_unite_all_upright(paths, precision), + } +} + +/// [`try_unite_all_with`] with the sweeps run on `paths` as they are. +pub(crate) fn try_unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> Result { let mut all = BezPath::new(); for (p, r) in paths { let bp = fill_bezpath(p); @@ -876,6 +1008,17 @@ pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData { /// [`unite_all`] refit to `precision`, dropping slivers thinner than it. pub fn unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> PathData { + match crate::sweep_axis::turned_fills(paths) { + Some(turned) => { + let refs: Vec<(&PathData, FillRule)> = turned.iter().map(|(p, r)| (p, *r)).collect(); + crate::sweep_axis::unturn(unite_all_upright(&refs, precision)) + } + None => unite_all_upright(paths, precision), + } +} + +/// [`unite_all_with`] with the sweeps run on `paths` as they are. +pub(crate) fn unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> PathData { let mut all = BezPath::new(); for (p, r) in paths { let bp = fill_bezpath(p); @@ -950,6 +1093,108 @@ mod tests { assert_eq!(tidy.repair(&on), on); } + #[test] + fn input_curves_and_kept_points_are_found_in_wide_and_tall_art() { + // Rows of short curves across the page, and the same stood on end: one lookup has a + // narrow window along x, the other along y. + let mut wide = BezPath::new(); + for row in 0..4 { + let y = row as f64 * 3.0; + wide.move_to((0.0, y)); + for k in 0..200 { + let x = k as f64; + wide.curve_to((x + 0.3, y + 1.0), (x + 0.7, y + 1.0), (x + 1.0, y)); + } + } + let tall = kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]) * &wide; + for art in [&wide, &tall] { + let tidy = Tidy::keeping(DEFAULT_PRECISION, [art]); + let mut sorted: Vec = (0..tidy.curves.len()).collect(); + sorted.sort_by(|&i, &j| tidy.curves[i].0.x0.total_cmp(&tidy.curves[j].0.x0).then(i.cmp(&j))); + for (_, c) in tidy.curves.iter().step_by(7) { + use kurbo::ParamCurve; + // A curve's start point is also held by the curve before it, whose bounds start a + // whole curve further back. + for (a, b) in [(c.p0, c.p3), (c.p0, c.p0), (c.eval(0.25), c.eval(0.75)), (c.p0, c.p0 + kurbo::Vec2::new(2.5, 2.5))] { + let by_hand: Vec<&CubicBez> = + sorted.iter().map(|&i| &tidy.curves[i]).filter(|(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c).collect(); + assert_eq!(tidy.curves_around(a, b).collect::>(), by_hand, "{a:?} {b:?}"); + } + for p in [c.p0, c.p0 + kurbo::Vec2::new(1e-7, -1e-7), c.p0 + kurbo::Vec2::new(0.0, 1e-3), c.p0 + kurbo::Vec2::new(1e-3, 0.0)] { + let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); + let by_hand = tidy.keep.iter().any(|q| (q.x - p.x).abs() <= tol && (q.y - p.y).abs() <= tol); + assert_eq!(tidy.is_kept(p), by_hand, "{p:?}"); + } + } + // The tall art's lookups reach the sorted lists along y; the wide art's never do. + let tall_art = std::ptr::eq(art, &tall); + assert_eq!(tidy.by_y.get().is_some(), tall_art); + assert_eq!(tidy.keep_by_y.get().is_some(), tall_art); + } + } + + #[test] + fn a_few_wide_lookups_do_not_sort_along_y() { + let mut tall = BezPath::new(); + for x in 0..2 { + let x = f64::from(x) * 3.0; + tall.move_to((x, 0.0)); + for k in 0..200 { + let y = f64::from(k); + tall.curve_to((x + 1.0, y + 0.3), (x + 1.0, y + 0.7), (x, y + 1.0)); + } + } + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&tall]); + let a = Point::new(0.0, 50.0); + let found = |tidy: &Tidy| tidy.curves_around(a, a).count(); + // Bounds at x = 0: the column's 200 curves are candidates along x, and two of them hold `a`. + assert_eq!(found(&tidy), 2); + assert!(tidy.is_kept(a)); + assert!(tidy.by_y.get().is_none()); + assert!(tidy.keep_by_y.get().is_none()); + // Enough of them to pay for the sort, and the answers stay the same. + let scanned = |l: &WideLookups| l.scanned.load(std::sync::atomic::Ordering::Relaxed); + while scanned(&tidy.curve_lookups) < SORT_COST * tidy.curves.len() || scanned(&tidy.point_lookups) < SORT_COST * tidy.keep.len() { + assert_eq!(found(&tidy), 2); + assert!(tidy.is_kept(a)); + } + assert_eq!(found(&tidy), 2); + assert!(tidy.is_kept(a)); + assert!(tidy.by_y.get().is_some()); + assert!(tidy.keep_by_y.get().is_some()); + // Along y the windows are narrow, so the lookups keep measuring there. + assert!(tidy.curve_lookups.score() > 0 && tidy.point_lookups.score() > 0); + } + + #[test] + fn lookups_measure_along_y_rarely_where_it_narrows_nothing() { + // A square grid of short curves: about as many candidates lie along y as along x. + let mut grid = BezPath::new(); + for row in 0..100 { + let y = f64::from(row) * 2.0; + grid.move_to((0.0, y)); + for k in 0..100 { + let x = f64::from(k); + grid.curve_to((x + 0.3, y + 0.5), (x + 0.7, y + 0.5), (x + 1.0, y)); + } + } + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&grid]); + let lookups: u32 = 20_000; + for k in 0..lookups { + let a = Point::new(f64::from(k % 97) + 0.5, f64::from(k % 89) * 2.0 + 0.2); + let found: Vec<&CubicBez> = tidy.curves_around(a, a).collect(); + if k % 500 == 0 { + let by_hand: Vec<&CubicBez> = tidy.by_x.iter().map(|&i| &tidy.curves[i]).filter(|(r, _)| r.contains(a)).map(|(_, c)| c).collect(); + assert_eq!(found, by_hand, "{a:?}"); + } + } + // The window along y was measured until the score fell below zero, then once in Y_RETRY lookups. + assert!(tidy.by_y.get().is_some()); + assert_eq!(tidy.curve_lookups.score(), -Y_SCORE_LIMIT); + let below = tidy.curve_lookups.below.load(std::sync::atomic::Ordering::Relaxed); + assert!(below > lookups as usize / 2, "{below} of {lookups} lookups below zero"); + } + #[test] fn a_lens_between_two_curves_through_the_same_points_stays() { // Two input curves meeting at both ends bound a real face: neither piece is "repaired" diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 7d289e135..80f0deffd 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -8,7 +8,7 @@ use vectorcraft_geom::path::measured_arclen; use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; use crate::boolean::{Seg, segs_to_subpath}; -use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, sample, start_tangent}; +use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, largest_second_difference, sample, start_tangent, unit}; /// Options for [`simplify_with`]. #[derive(Clone, Copy, Debug, PartialEq)] @@ -96,14 +96,66 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { let k = (0..n).find(|&i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) > opts.corner_angle_deg).unwrap_or(0); segs.rotate_left(k); } - let result = simplify_run(&segs, opts); + // A smoothed loop with no corner is fitted along one direction across its start, when no segment + // stays as it is and the fit has one part. A nearly straight first or last curve becomes a line + // along its chord, and `no_worse` can return the original subpath, so the start can keep a kink. + let across = if sp.closed && opts.smooth > 0.0 { unit(end_tangent(&segs[n - 1].c) + start_tangent(&segs[0].c)) } else { None }; + let across = across.filter(|_| (0..n).all(|i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) <= opts.corner_angle_deg)); + let result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across); let s = segs_to_subpath(&result, sp.closed)?; Some(if opts.straight_lines { s } else { no_worse(sp, s) }) } +/// How far a segment may be from the lines between its samples, as a share of the tolerance. +const SAMPLE_GAP: f64 = 0.005; + +/// Most samples of one segment. A segment that needs more stays as it is. +const MAX_SEGMENT_SAMPLES: usize = 1 << 16; + +/// Most samples of one fit, which limits the memory a fit holds. A run with more is fitted in parts, +/// with an anchor between them. +pub(crate) const MAX_FIT_SAMPLES: usize = 1 << 20; + +/// Most samples of one run, which limits its time. The segments after the run reaches this stay as +/// they are. +const MAX_RUN_SAMPLES: usize = 1 << 24; + +/// Most segments of the result of one run before its other segments stay as they are, which limits +/// the memory it holds. A fit under way can add up to [`MAX_FIT_SAMPLES`] more. +pub(crate) const MAX_RUN_RESULT: usize = 1 << 21; + +/// How many evenly spaced steps along `c` keep it within [`SAMPLE_GAP`] of `tol` from the lines +/// between its samples. Over a step of `1 / m`, a curve stays within `0.75 * s / (m * m)` of the line +/// between its ends, where `s` is its largest second difference. The samples of a straight segment +/// are on its line, so their spacing follows its length and `tol`, up to 256. +pub(crate) fn sample_steps(c: &CubicBez, tol: f64) -> usize { + // Guarded: kurbo subdivides without end on huge finite coordinates. + let len = measured_arclen(&PathSeg::Cubic(*c), 1e-6); + let by_length = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256); + if is_straight(c, tol * SAMPLE_GAP * 0.5) { + return by_length; + } + by_length.max((0.75 * largest_second_difference(c) / (tol * SAMPLE_GAP)).sqrt().ceil() as usize) +} + +/// How many evenly spaced straight lines keep `c` within `tol` of them. +fn line_steps(c: &CubicBez, tol: f64) -> usize { + (0.75 * largest_second_difference(c) / tol).sqrt().ceil() as usize +} + /// The refit of the consecutive segments `run`, split at its corners sharper than the threshold: /// it starts and ends where `run` does, leaving the same way. fn simplify_run(run: &[Seg], opts: &SimplifyOptions) -> Vec { + simplify_run_within(run, opts, MAX_RUN_SAMPLES, None) +} + +/// [`simplify_run`] with at most `budget` samples. A segment that needs more samples than +/// [`MAX_SEGMENT_SAMPLES`] or than the budget has left, or that comes after [`MAX_RUN_RESULT`] segments +/// of result, stays as it is, and the segments on each side of it are refitted without it. With +/// `straight_lines` such a segment becomes [`line_steps`] lines, or as many as those limits allow when +/// that is fewer, and then it can pass the tolerance. `ends`, when the whole run is refitted as one +/// part, is the direction the refit leaves and arrives along. +pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budget: usize, ends: Option) -> Vec { let tol = opts.tolerance.max(1e-6); let mut cuts: Vec = (1..run.len()).filter(|&i| turn_deg(&run[i - 1], &run[i]) > opts.corner_angle_deg).collect(); cuts.insert(0, 0); @@ -111,39 +163,85 @@ fn simplify_run(run: &[Seg], opts: &SimplifyOptions) -> Vec { let mut result: Vec = Vec::new(); for w in cuts.windows(2) { let piece = &run[w[0]..w[1]]; - if piece.is_empty() { - continue; - } - let mut pts = Vec::new(); - for (k, s) in piece.iter().enumerate() { - // Guarded: kurbo subdivides without end on huge finite coordinates. - let len = measured_arclen(&PathSeg::Cubic(s.c), 1e-6); - let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256); - sample(&s.c, m, &mut pts, k == 0); - } - if opts.smooth > 0.0 { - pts = smooth_points(&pts, opts.smooth); - } - if opts.straight_lines { - let keep = rdp(&pts, tol); - result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1]))); - continue; - } - let t0 = start_tangent(&piece[0].c); - let t1 = end_tangent(&piece[piece.len() - 1].c); - for c in fit_cubics(&pts, t0, t1, tol) { - let line = is_straight(&c, tol * 0.02); - result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); + let mut part: Vec<(&Seg, usize)> = Vec::new(); + let mut part_samples = 0; + for s in piece { + let steps = sample_steps(&s.c, tol); + if steps <= budget.min(MAX_SEGMENT_SAMPLES) && result.len() < MAX_RUN_RESULT { + if part_samples + steps > MAX_FIT_SAMPLES { + refit_part(&part, opts, tol, None, &mut result); + part.clear(); + part_samples = 0; + } + budget -= steps; + part_samples += steps; + part.push((s, steps)); + } else { + part_samples = 0; + refit_part(&part, opts, tol, None, &mut result); + part.clear(); + if opts.straight_lines { + let room = MAX_RUN_RESULT.saturating_sub(result.len()); + let lines = line_steps(&s.c, tol).min(budget.min(MAX_SEGMENT_SAMPLES)).min(room).max(1); + budget = budget.saturating_sub(lines); + let mut pts = Vec::new(); + sample(&s.c, lines, &mut pts, true); + result.extend(pts.windows(2).map(|p| Seg::line(p[0], p[1]))); + } else { + result.push(*s); + } + } } + refit_part(&part, opts, tol, ends.filter(|_| part.len() == run.len()), &mut result); } result } +/// Adds the refit of `part`, consecutive segments with no corner between them and the steps to +/// sample each one with, to `result`. `ends` is the direction it leaves and arrives along, when not +/// those of its first and last segments. +fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, ends: Option, result: &mut Vec) { + let (Some((first, _)), Some((last, _))) = (part.first(), part.last()) else { return }; + let mut pts = Vec::new(); + for (k, (s, steps)) in part.iter().enumerate() { + sample(&s.c, *steps, &mut pts, k == 0); + } + if opts.smooth > 0.0 { + pts = smooth_points(&pts, opts.smooth); + } + if opts.straight_lines { + let keep = rdp(&pts, tol * (1.0 - SAMPLE_GAP)); + result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1]))); + return; + } + // The fit may be `tol * 0.98` from the lines between the samples, those lines may be + // `SAMPLE_GAP` of `tol` from the path, and the curve the test below turns into a line moves by up + // to 1.5% of `tol`: 3/4 of how far its handles may be from the line. + let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d)); + for c in fit_cubics(&pts, t0, t1, tol * 0.98) { + let line = is_straight(&c, tol * 0.02); + result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); + } +} + /// `pts` (a run's samples, in order) smoothed along the path: each point becomes the Gaussian-weighted /// mean of the points around it, `sigma` points of arc length wide, the window narrowing towards the -/// ends so that the first and last points stay where they are. Most points looked at per point is -/// bounded by the samples within three spreads of it. +/// ends so that the first and last points stay where they are. Consecutive samples within +/// 1/[`SMOOTH_GROUP`] of a spread of each other along the path are weighed together, as their count at +/// their mean place, so the work per point is bounded by the groups within three spreads of it however +/// densely the path is sampled. fn smooth_points(pts: &[Point], sigma: f64) -> Vec { + smooth_points_counted(pts, sigma, &mut 0) +} + +/// How many groups of samples a spread holds at most in [`smooth_points`]. Weighing them in groups +/// leaves each smoothed point within a thousandth of a spread of the mean of its samples weighed one +/// by one. +const SMOOTH_GROUP: f64 = 16.0; + +/// [`smooth_points`], adding the number of sample groups it looks at to `work`. +fn smooth_points_counted(pts: &[Point], sigma: f64, work: &mut usize) -> Vec { + let Some(&origin) = pts.first() else { return Vec::new() }; if pts.len() < 3 || !(sigma.is_finite() && sigma > 0.0) { return pts.to_vec(); } @@ -155,6 +253,7 @@ fn smooth_points(pts: &[Point], sigma: f64) -> Vec { } s.push(total); } + let groups = SampleGroups::new(pts, &s, origin); let (mut lo, mut hi) = (0usize, 0usize); pts.iter() .zip(&s) @@ -170,17 +269,99 @@ fn smooth_points(pts: &[Point], sigma: f64) -> Vec { while s.get(hi + 1).is_some_and(|&v| v <= si + reach) { hi += 1; } - let (mut sum, mut weight) = (Vec2::ZERO, 0.0); - for (q, &sj) in pts.iter().zip(&s).take(hi + 1).skip(lo) { - let w = (-(sj - si) * (sj - si) / (2.0 * spread * spread)).exp(); - sum += q.to_vec2() * w; - weight += w; - } - if weight > 0.0 { (sum / weight).to_point() } else { p } + let window = Window { lo, end: hi + 1, at: si, spread, group: spread / SMOOTH_GROUP }; + let mut acc = Weighed::default(); + groups.weigh(0, (0, pts.len()), &window, &mut acc); + *work += acc.work; + if acc.weight > 0.0 { origin + acc.sum / acc.weight } else { p } }) .collect() } +/// The samples [`smooth_points`] weighs for one point: indices `lo..end`, weighed by a Gaussian of +/// `spread` around arc length `at`, in groups at most `group` long along the path. +struct Window { + lo: usize, + end: usize, + at: f64, + spread: f64, + group: f64, +} + +/// The sums of halves, quarters and so on of a run's samples, by index: node 0 holds all of them and +/// node `k`'s halves are nodes `2k + 1` and `2k + 2`. +struct SampleGroups<'a> { + /// Arc length of each sample. + s: &'a [f64], + /// Sum of the arc lengths of each node's samples. + sum_s: Vec, + /// Sum of each node's samples, measured from the first sample. + sum_q: Vec, +} + +impl<'a> SampleGroups<'a> { + fn new(pts: &[Point], s: &'a [f64], origin: Point) -> Self { + let size = 4 * pts.len(); + let mut groups = Self { s, sum_s: vec![0.0; size], sum_q: vec![Vec2::ZERO; size] }; + groups.build(0, 0, pts.len(), pts, origin); + groups + } + + fn build(&mut self, node: usize, l: usize, r: usize, pts: &[Point], origin: Point) { + if r <= l { + return; + } + let (sum_s, sum_q) = if r - l == 1 { + (self.s.get(l).copied().unwrap_or(0.0), pts.get(l).map_or(Vec2::ZERO, |&p| p - origin)) + } else { + let m = l + (r - l) / 2; + self.build(2 * node + 1, l, m, pts, origin); + self.build(2 * node + 2, m, r, pts, origin); + let at = |v: &Vec, k: usize| v.get(k).copied().unwrap_or(0.0); + let q = |k: usize| self.sum_q.get(k).copied().unwrap_or(Vec2::ZERO); + (at(&self.sum_s, 2 * node + 1) + at(&self.sum_s, 2 * node + 2), q(2 * node + 1) + q(2 * node + 2)) + }; + if let Some(a) = self.sum_s.get_mut(node) { + *a = sum_s; + } + if let Some(a) = self.sum_q.get_mut(node) { + *a = sum_q; + } + } + + /// Adds the weighted samples of node `node` (indices `l..r`) that are inside `w` to `acc`: a node + /// inside `w` no longer than its groups as its count at its mean arc length, any other node by + /// its halves. + fn weigh(&self, node: usize, (l, r): (usize, usize), w: &Window, acc: &mut Weighed) { + if r <= w.lo || l >= w.end || r <= l { + return; + } + acc.work += 1; + let length = self.s.get(r - 1).copied().unwrap_or(0.0) - self.s.get(l).copied().unwrap_or(0.0); + // One sample is always a group, even where arc lengths that overflowed leave `length` NaN. + if r - l == 1 || (w.lo <= l && r <= w.end && length <= w.group) { + let count = (r - l) as f64; + let t = self.sum_s.get(node).copied().unwrap_or(0.0) / count - w.at; + let g = (-t * t / (2.0 * w.spread * w.spread)).exp(); + acc.sum += self.sum_q.get(node).copied().unwrap_or(Vec2::ZERO) * g; + acc.weight += count * g; + return; + } + let m = l + (r - l) / 2; + self.weigh(2 * node + 1, (l, m), w, acc); + self.weigh(2 * node + 2, (m, r), w, acc); + } +} + +/// What [`SampleGroups::weigh`] has added up: the weighted sum of the samples (from the first +/// sample), the sum of the weights, and the number of nodes looked at. +#[derive(Default)] +struct Weighed { + sum: Vec2, + weight: f64, + work: usize, +} + /// What [`simplify_part`] made of a path. #[derive(Clone, Debug, PartialEq)] pub struct SimplifiedPart { @@ -295,7 +476,8 @@ fn with_handle(a: Anchor, out: bool, h: Point) -> Anchor { Anchor { p: a.p, h_in, h_out, kind } } -/// Ramer–Douglas–Peucker polyline reduction. +/// Ramer–Douglas–Peucker polyline reduction. Each point dropped is within `tol` of the line segment +/// that replaces it: a point beyond either end of the segment is measured to that end. fn rdp(pts: &[Point], tol: f64) -> Vec { if pts.len() < 3 { return pts.to_vec(); @@ -317,10 +499,10 @@ fn rdp(pts: &[Point], tol: f64) -> Vec { }; let (pa, pb) = (pts[a], pts[b]); let chord = pb - pa; - let len = chord.hypot(); + let len2 = chord.hypot2(); let mut best = (0.0, a); for (i, &p) in pts.iter().enumerate().take(b).skip(a + 1) { - let d = if len < 1e-12 { p.distance(pa) } else { (p - pa).cross(chord).abs() / len }; + let d = if len2 < 1e-24 { p.distance(pa) } else { p.distance(pa + chord * ((p - pa).dot(chord) / len2).clamp(0.0, 1.0)) }; if d > best.0 { best = (d, i); } @@ -808,3 +990,116 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, (row_gutter, col_gu fn end_point(sp: &SubPath, last: bool) -> Option { if last { sp.anchors.last() } else { sp.anchors.first() }.map(|a| a.p) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The samples Object › Path › Smooth at `level` smooths for `sp`, a subpath with no corner, + /// and the spread it smooths them with. + fn smooth_samples(sp: &SubPath, level: f64) -> (Vec, f64) { + let opts = smooth_options(&PathData::single(sp.clone()), level); + let mut pts = Vec::new(); + for (k, s) in subpath_segs(sp).iter().enumerate() { + sample(&s.c, sample_steps(&s.c, opts.tolerance), &mut pts, k == 0); + } + (pts, opts.smooth) + } + + /// A traced outline: `n` straight segments around a circle of radius 200, each anchor moved in + /// or out by up to 0.5 pt. + fn traced_circle(n: usize) -> SubPath { + let pts: Vec = (0..n) + .map(|i| { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + let r = 200.0 + 0.5 * ((i * 7919) % 13) as f64 / 13.0; + Point::new(300.0 + r * a.cos(), 300.0 + r * a.sin()) + }) + .collect(); + SubPath::polyline(&pts, true) + } + + /// [`smooth_points`] weighing every sample on its own. + fn smooth_points_one_by_one(pts: &[Point], sigma: f64) -> Vec { + let mut s = vec![0.0]; + for w in pts.windows(2) { + s.push(s.last().copied().unwrap_or(0.0) + w[0].distance(w[1])); + } + let total = s.last().copied().unwrap_or(0.0); + pts.iter() + .zip(&s) + .map(|(&p, &si)| { + let reach = (3.0 * sigma).min(si).min(total - si); + if reach <= 1e-9 { + return p; + } + let spread = reach / 3.0; + let (mut sum, mut weight) = (Vec2::ZERO, 0.0); + for (q, &sj) in pts.iter().zip(&s).filter(|(_, sj)| (*sj - si).abs() <= reach) { + let w = (-(sj - si) * (sj - si) / (2.0 * spread * spread)).exp(); + sum += (*q - p) * w; + weight += w; + } + p + sum / weight + }) + .collect() + } + + /// Next value in 0..1 of a small linear congruential generator. + fn next(seed: &mut u64) -> f64 { + *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (*seed >> 11) as f64 / (1u64 << 53) as f64 + } + + #[test] + fn smoothing_work_per_sample_stays_bounded_on_dense_paths() { + // Weighing every sample within three spreads took 360 steps per sample for 1,000 anchors and + // 2,949 for 16,000: the work grew with the square of the anchor count. + for n in [1_000usize, 16_000] { + let (pts, sigma) = smooth_samples(&traced_circle(n), 100.0); + let mut work = 0; + smooth_points_counted(&pts, sigma, &mut work); + assert!(work <= 400 * pts.len(), "{n} anchors: {work} steps for {} samples", pts.len()); + } + // A curve sampled at a tiny tolerance and smoothed far beyond its length. + let c = CubicBez::new((0.0, 0.0), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0)); + let mut pts = Vec::new(); + sample(&c, sample_steps(&c, 0.00001), &mut pts, true); + let mut work = 0; + smooth_points_counted(&pts, 1000.0, &mut work); + assert!(work <= 400 * pts.len(), "{work} steps for {} samples", pts.len()); + } + + #[test] + fn smoothing_points_whose_arc_length_overflows_returns_every_point() { + let pts: Vec = (0..40).map(|i| Point::new(if i % 2 == 0 { -1e308 } else { 1e308 }, f64::from(i))).collect(); + assert_eq!(smooth_points(&pts, 10.0).len(), pts.len()); + } + + #[test] + fn grouped_smoothing_stays_within_a_thousandth_of_a_spread_of_weighing_one_by_one() { + let mut seed = 7u64; + for case in 0..300 { + let n = 3 + (next(&mut seed) * 2000.0) as usize; + let mut p = Point::ZERO; + let mut dir: f64 = 0.0; + let dense = next(&mut seed) < 0.5; + let pts: Vec = (0..n) + .map(|_| { + dir += (next(&mut seed) - 0.5) * 1.5; + let step = if dense { next(&mut seed) * 0.05 } else { next(&mut seed) * 5.0 }; + p += Vec2::new(dir.cos(), dir.sin()) * step; + p + }) + .collect(); + let sigma = 0.01 + next(&mut seed) * 50.0; + let exact = smooth_points_one_by_one(&pts, sigma); + let grouped = smooth_points(&pts, sigma); + assert_eq!(grouped.len(), exact.len()); + for (i, (a, b)) in exact.iter().zip(&grouped).enumerate() { + assert!(a.distance(*b) <= 1e-3 * sigma, "case {case}, point {i}: {a:?} and {b:?}, spread {sigma}"); + } + assert_eq!((grouped.first(), grouped.last()), (pts.first(), pts.last()), "case {case}"); + } + } +} diff --git a/crates/pathops/src/fit.rs b/crates/pathops/src/fit.rs index 091164d20..6b1f4eb0c 100644 --- a/crates/pathops/src/fit.rs +++ b/crates/pathops/src/fit.rs @@ -1,12 +1,22 @@ //! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end -//! tangents, then reparameterise with Newton steps, then split at the worst point" approach from +//! tangents, then reparameterise with Newton steps, then split near the worst point" approach from //! Graphics Gems, written from the published description). -use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Point, Vec2}; +use kurbo::{CubicBez, Line, ParamCurve, ParamCurveDeriv, ParamCurveNearest, Point, Vec2}; /// Maximum Newton reparameterisation rounds per fit. const MAX_REPARAM: usize = 6; +/// Most pieces joined to the first piece of a fitted curve. Each join refits every point of the +/// curve, so this limits how many times a point is refitted. +const MAX_JOINS: usize = 32; + +/// Most samples of a fitted curve checked between two neighboring points. See [`bulge`]. +const MAX_BULGE_SAMPLES: f64 = 1024.0; + +/// About how many points of a long piece its first fit uses. +const COARSE_POINTS: usize = 16; + /// Unit vector or `None` if (nearly) zero. pub(crate) fn unit(v: Vec2) -> Option { let l = v.hypot(); @@ -28,15 +38,43 @@ pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 { /// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point /// (forward direction). Consecutive output cubics are G1 continuous. pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec { - let mut out = Vec::new(); let pts = dedup(pts); if pts.len() < 2 { - return out; + return Vec::new(); + } + let tol = tol.max(1e-9); + let mut pieces = Vec::new(); + fit_rec(&pts, 0, pts.len() - 1, t0, t1, tol, &mut pieces); + join_pieces(&pts, &pieces, t0, tol) +} + +/// The curves of `pieces`, which follow each other along `pts` from its first point, leaving it +/// along `t0`. Each curve takes up to [`MAX_JOINS`] of the pieces after its first one, as long as +/// the fit of them together is within `tol`. +fn join_pieces(pts: &[Point], pieces: &[Piece], t0: Vec2, tol: f64) -> Vec { + let mut out = Vec::new(); + let (mut start, mut leaving, mut next) = (0, t0, 0); + while let Some(first) = pieces.get(next) { + let (mut curve, mut end, mut arriving) = (first.curve, first.end, first.t1); + next += 1; + for piece in pieces.iter().skip(next).take(MAX_JOINS) { + let Ok(joined) = fit_piece(&pts[start..=piece.end], leaving, piece.t1, tol) else { break }; + (curve, end, arriving) = (joined, piece.end, piece.t1); + next += 1; + } + out.push(curve); + (start, leaving) = (end, arriving); } - fit_rec(&pts, t0, t1, tol.max(1e-9), &mut out, 0); out } +/// A fitted piece: its curve, the index of the point it ends on and its tangent there. +struct Piece { + curve: CubicBez, + end: usize, + t1: Vec2, +} + fn dedup(pts: &[Point]) -> Vec { let mut v: Vec = Vec::with_capacity(pts.len()); for &p in pts { @@ -47,23 +85,65 @@ fn dedup(pts: &[Point]) -> Vec { v } -fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec, depth: usize) { +/// Fits `all[start..=end]`, splitting a piece in two until each piece is within `tol`. A piece of +/// two points is a cubic through both with handles along the tangents, at most `tol` long, so it +/// stays within `tol` of the line between them. +/// +/// The split point is the piece's worst point moved, when it is near an end, to two fifths of the +/// piece from that end. Each half then holds about three fifths of the points at most, so the +/// recursion depth is logarithmic in the number of points. +fn fit_rec(all: &[Point], start: usize, end: usize, t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec) { + let pts = &all[start..=end]; let n = pts.len(); if n == 2 { - let d = pts[0].distance(pts[1]) / 3.0; - out.push(CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1])); + let d = (pts[0].distance(pts[1]) / 3.0).min(tol); + out.push(Piece { curve: CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1]), end, t1 }); return; } - let (c, err, split) = fit_single(pts, t0, t1); - if err <= tol || depth > 40 { - out.push(c); - return; + let split = match fit_piece(pts, t0, t1, tol) { + Ok(curve) => { + out.push(Piece { curve, end, t1 }); + return; + } + Err(worst) => worst, + }; + let margin = (n * 2 / 5).max(1); + let split = split.clamp(margin, n - 1 - margin); + let tc = center_tangent(pts, split, tol).unwrap_or(t0); + fit_rec(all, start, start + split, t0, tc, tol, out); + fit_rec(all, start + split, end, tc, t1, tol, out); +} + +/// One cubic for three or more `pts`, when every point is within `tol` of it and it is within `tol` +/// of the lines between the points; otherwise the index of a point where it is not. A first fit over +/// 8 times `tol` plus 5% of the chord is not reparameterized. +/// +/// A piece of many points is first fitted to about [`COARSE_POINTS`] of them, and it is split when +/// that fit is over `tol` at one of those. Each split can be undone by [`join_pieces`]. +fn fit_piece(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Result { + let give_up = tol * 8.0 + pts[0].distance(pts[pts.len() - 1]) * 0.05; + if pts.len() >= COARSE_POINTS * 2 { + let stride = pts.len() / COARSE_POINTS; + let some: Vec = pts.iter().step_by(stride).chain(pts.last()).copied().collect(); + let (_, err, worst, _) = fit_single_within(&some, chord_params(&some), t0, t1, give_up); + if err > tol { + return Err((worst * stride).min(pts.len() - 1)); + } } - // Split at the worst point with a centre tangent estimated from its neighbours. - let split = split.clamp(1, n - 2); - let tc = unit(pts[split + 1] - pts[split - 1]).unwrap_or(t0); - fit_rec(&pts[..=split], t0, tc, tol, out, depth + 1); - fit_rec(&pts[split..], tc, t1, tol, out, depth + 1); + let (c, err, worst, u) = fit_single_within(pts, chord_params(pts), t0, t1, give_up); + if err > tol { + return Err(worst); + } + bulge(pts, &c, &u, tol).map_or(Ok(c), Err) +} + +/// Direction at `pts[split]`, between the nearest points on each side that are over `tol` from it, +/// or the first and last points when none is, so that details smaller than `tol` do not turn it. +fn center_tangent(pts: &[Point], split: usize, tol: f64) -> Option { + let p = pts[split]; + let before = pts[..split].iter().rev().find(|q| q.distance(p) > tol).unwrap_or(&pts[0]); + let after = pts[split + 1..].iter().find(|q| q.distance(p) > tol).unwrap_or(&pts[pts.len() - 1]); + unit(*after - *before) } /// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of @@ -74,9 +154,20 @@ pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, u /// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the /// chord-length ones. -pub(crate) fn fit_single_from(pts: &[Point], mut u: Vec, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) { +pub(crate) fn fit_single_from(pts: &[Point], u: Vec, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) { + let (c, err, worst, _) = fit_single_within(pts, u, t0, t1, f64::INFINITY); + (c, err, worst) +} + +/// [`fit_single_from`] that also returns the fit's parameters, and returns the first fit without +/// any reparameterization when its error is over `give_up`. +fn fit_single_within(pts: &[Point], mut u: Vec, t0: Vec2, t1: Vec2, give_up: f64) -> (CubicBez, f64, usize, Vec) { let mut best = generate(pts, &u, t0, t1); let (mut best_err, mut best_split) = max_error(pts, &best, &u); + if best_err > give_up { + return (best, best_err, best_split, u); + } + let mut best_u = u.clone(); for _ in 0..MAX_REPARAM { for (i, ui) in u.iter_mut().enumerate() { *ui = newton(&best, pts[i], *ui); @@ -87,11 +178,42 @@ pub(crate) fn fit_single_from(pts: &[Point], mut u: Vec, t0: Vec2, t1: Vec2 best = c; best_err = e; best_split = s; + best_u.copy_from_slice(&u); } else { break; } } - (best, best_err, best_split) + (best, best_err, best_split, best_u) +} + +/// Index of the first point of `pts` after which the curve `c`, between that point's parameter in +/// `u` and the next point's, is over 99% of `tol` from the line between the two points at a sample. +/// +/// Over a parameter step `h`, a curve stays within `h * h * m / 8` of the chord between its ends, +/// where `m` is its largest second derivative, at most 6 times the control points' largest second +/// difference. The samples are close enough to keep that to 1% of `tol`. A pair of points that would +/// need more than [`MAX_BULGE_SAMPLES`] counts as a point where the curve is too far. +fn bulge(pts: &[Point], c: &CubicBez, u: &[f64], tol: f64) -> Option { + let step = (0.01 * tol * 8.0 / (6.0 * largest_second_difference(c))).sqrt(); + let limit = tol * 0.99; + pts.windows(2).zip(u.windows(2)).position(|(p, t)| { + let line = Line::new(p[0], p[1]); + let samples = ((t[1] - t[0]).abs() / step).ceil().max(1.0); + if samples > MAX_BULGE_SAMPLES { + return true; + } + let samples = samples as usize; + (1..=samples).any(|k| { + let at = t[0] + (t[1] - t[0]) * k as f64 / (samples + 1) as f64; + line.nearest(c.eval(at), 1e-9).distance_sq > limit * limit + }) + }) +} + +/// The larger second difference of the control points of `c`. Its largest second derivative is 6 +/// times this. +pub(crate) fn largest_second_difference(c: &CubicBez) -> f64 { + (c.p2 - c.p1 - (c.p1 - c.p0)).hypot().max((c.p3 - c.p2 - (c.p2 - c.p1)).hypot()) } fn chord_params(pts: &[Point]) -> Vec { @@ -200,6 +322,7 @@ pub(crate) fn sample(c: &CubicBez, n: usize, out: &mut Vec, include_start #[cfg(test)] mod tests { use super::*; + use vectorcraft_geom::{Anchor, PathData, SubPath}; #[test] fn fits_a_cubic_exactly() { @@ -222,6 +345,530 @@ mod tests { } } + /// Numbers from 0 to 1, the same ones for the same `seed`. + fn random(mut seed: u64) -> impl FnMut() -> f64 { + move || { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (seed >> 11) as f64 / (1u64 << 53) as f64 + } + } + + /// Largest distance from a point of `pts` to the nearest of the cubics `cs`, or infinity when + /// a point is more than `reach` from the bounding box of every cubic. + fn farthest(pts: &[Point], cs: &[CubicBez], reach: f64) -> f64 { + use kurbo::ParamCurveExtrema; + let boxes: Vec<_> = cs.iter().map(|c| c.bounding_box().inflate(reach, reach)).collect(); + pts.iter() + .map(|&p| { + cs.iter().zip(&boxes).filter(|(_, b)| b.contains(p)).map(|(c, _)| c.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min) + }) + .fold(0.0, f64::max) + } + + fn cubics(path: &PathData) -> Vec { + path.subpaths.iter().flat_map(|s| (0..s.segment_count()).map(|i| s.segment(i))).collect() + } + + fn samples(cs: &[CubicBez]) -> Vec { + let mut pts = Vec::new(); + for c in cs { + sample(c, 16, &mut pts, true); + } + pts + } + + /// Samples of `cs` that follow each curve to within 1% of `tol`, at least 16 per curve. + fn close_samples(cs: &[CubicBez], tol: f64) -> Vec { + let mut pts = Vec::new(); + for c in cs { + let steps = (0.75 * largest_second_difference(c) / (0.01 * tol)).sqrt().ceil().clamp(16.0, 20000.0); + sample(c, steps as usize, &mut pts, true); + } + pts + } + + /// Simplify of `path` has no more anchors, and each of the path and its result is within `tol` + /// of the other, measured on the curves themselves. Returns the result. + fn assert_simplifies_within(path: &PathData, tol: f64, name: &str) -> PathData { + let out = crate::simplify_with(path, &crate::SimplifyOptions { tolerance: tol, ..Default::default() }); + assert!(out.anchor_count() <= path.anchor_count(), "{name}: {} anchors from {}", out.anchor_count(), path.anchor_count()); + assert_within(&cubics(path), &cubics(&out), tol, name); + out + } + + /// Each of `before` and `after` is within `tol` of the other, measured on the curves themselves. + fn assert_within(before: &[CubicBez], after: &[CubicBez], tol: f64, name: &str) { + let limit = tol * (1.0 + 1e-6); + let to_result = farthest(&close_samples(before, tol), after, limit * 8.0); + assert!(to_result <= limit, "{name}: a point of the path is {to_result} from the result at a tolerance of {tol}"); + let to_path = farthest(&close_samples(after, tol), before, limit * 8.0); + assert!(to_path <= limit, "{name}: a point of the result is {to_path} from the path at a tolerance of {tol}"); + } + + /// The segments of `sp` as a run for `simplify_run_within`. + fn run_of(sp: &SubPath) -> Vec { + (0..sp.segment_count()).map(|i| crate::boolean::Seg { c: sp.segment(i), line: false }).collect() + } + + /// Anchors along a circle of radius `r` about the origin, from `first` degrees in steps of + /// `step` degrees, smooth, with the handles of a circular arc. + fn arc_anchors(r: f64, first: f64, step: f64, count: usize) -> Vec { + let handle = r * 4.0 / 3.0 * (step.to_radians() / 4.0).tan(); + (0..count) + .map(|i| { + let (s, c) = (first + i as f64 * step).to_radians().sin_cos(); + let p = Point::new(r * c, r * s); + Anchor::smooth(p, p + Vec2::new(-s, c) * handle) + }) + .collect() + } + + /// `count` corner anchors 0.5 pt apart, going on from `from` along `dir`. + fn short_anchors(from: Point, dir: Vec2, count: usize) -> Vec { + (1..=count).map(|i| Anchor::corner(from + dir * (i as f64 * 0.5))).collect() + } + + #[test] + fn long_arcs_stay_within_the_tolerance() { + // Simplify keeps a subpath as it was when the refit has more anchors, so this measures the + // refit itself. + for (r, tol) in [(1_000_000.0, 2.0), (1_000_000.0, 1.0), (100_000.0, 0.1), (100_000.0, 1.0 / 32.0), (20_000.0, 0.01), (5_000.0, 0.01)] { + let run = run_of(&SubPath::new(arc_anchors(r, 0.0, 90.0, 4), false)); + let opts = crate::SimplifyOptions { tolerance: tol, ..Default::default() }; + let out = crate::edit::simplify_run_within(&run, &opts, usize::MAX, None); + let (before, after): (Vec<_>, Vec<_>) = (run.iter().map(|s| s.c).collect(), out.iter().map(|s| s.c).collect()); + assert_within(&before, &after, tol, &format!("arcs of {r} pt")); + assert!(after.len() < 200, "arcs of {r} pt at {tol} pt give {} curves", after.len()); + } + } + + #[test] + fn long_arcs_and_short_anchors_stay_within_the_tolerance() { + for (r, tol) in [(100_000.0, 1.0), (100_000.0, 0.1), (100_000.0, 1.0 / 32.0), (20_000.0, 0.01), (5_000.0, 0.01), (1_000_000.0, 1.0)] { + let mut anchors = arc_anchors(r, 0.0, 90.0, 4); + let end = anchors[3].p; + anchors.extend(short_anchors(end, Vec2::new(1.0, 0.0), 3000)); + let out = assert_simplifies_within(&PathData::single(SubPath::new(anchors, false)), tol, &format!("arcs of {r} pt and short anchors")); + assert!(out.anchor_count() < 200, "arcs of {r} pt at {tol} pt give {} anchors", out.anchor_count()); + } + } + + #[test] + fn long_arcs_converted_to_straight_lines_stay_within_the_tolerance() { + for (r, tol) in [(100_000.0, 0.1), (20_000.0, 0.01)] { + let mut anchors = arc_anchors(r, 0.0, 90.0, 4); + let end = anchors[3].p; + anchors.extend(short_anchors(end, Vec2::new(1.0, 0.0), 3000)); + let path = PathData::single(SubPath::new(anchors, false)); + let out = crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: tol, straight_lines: true, ..Default::default() }); + assert_within(&cubics(&path), &cubics(&out), tol, &format!("lines along arcs of {r} pt")); + } + } + + #[test] + fn a_path_that_turns_back_past_its_end_converted_to_straight_lines_keeps_its_tip() { + // A line to (100, 0), a smooth 0.2 pt U-turn and a line back to (50, 0.2): every point is + // within 0.4 pt of the line through the ends, but the tip is 50 pt past the end. + let k = 0.2 * 2.0 / 3.0; + let (a, b) = (Point::new(100.0, 0.0), Point::new(100.0, 0.2)); + let anchors = vec![ + Anchor::corner(Point::new(0.0, 0.0)), + Anchor::with_handles(a, a, Point::new(100.0 + k, 0.0)), + Anchor::with_handles(b, Point::new(100.0 + k, 0.2), b), + Anchor::corner(Point::new(50.0, 0.2)), + ]; + let path = PathData::single(SubPath::new(anchors, false)); + let out = crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: 1.0, straight_lines: true, ..Default::default() }); + assert_within(&cubics(&path), &cubics(&out), 1.0, "a U-turn past the end"); + } + + #[test] + fn long_and_short_segments_stay_within_the_tolerance() { + for (r, tol) in [(50_000.0, 2.0), (50_000.0, 1.0 / 32.0), (8_000.0, 0.01), (500.0, 0.5)] { + let mut anchors = Vec::new(); + for block in 0..6 { + let first = f64::from(block) * 60.0; + let arc = arc_anchors(r, first, 20.0, 3); + let (s, c) = (first + 40.0).to_radians().sin_cos(); + let end = arc[2].p; + anchors.extend(arc); + anchors.extend(short_anchors(end, Vec2::new(-s, c), 300)); + } + assert_simplifies_within(&PathData::single(SubPath::new(anchors, false)), tol, &format!("radius {r}")); + } + } + + #[test] + fn a_segment_that_needs_too_many_samples_stays_as_it_is() { + // Radius 10,000,000 pt at 0.001 pt needs about 770,000 steps per quarter circle. + let mut anchors = arc_anchors(10_000_000.0, 0.0, 90.0, 4); + let end = anchors[3].p; + anchors.extend(short_anchors(end, Vec2::new(1.0, 0.0), 500)); + let path = PathData::single(SubPath::new(anchors, false)); + let out = crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: 0.001, ..Default::default() }); + assert_eq!(cubics(&out)[..3], cubics(&path)[..3]); + assert!(out.anchor_count() < 20, "{} anchors", out.anchor_count()); + } + + #[test] + fn a_segment_that_needs_too_many_samples_becomes_lines_when_asked_for_lines() { + // About 55,000 lines keep this arc within 0.001 pt, and about 770,000 samples would be needed + // to fit it. + let (r, tol) = (10_000_000.0, 0.001); + let path = PathData::single(SubPath::new(arc_anchors(r, 0.0, 90.0, 2), false)); + let arc = cubics(&path)[0]; + let out = cubics(&crate::simplify_with(&path, &crate::SimplifyOptions { tolerance: tol, straight_lines: true, ..Default::default() })); + assert!(out.iter().all(|c| is_straight(c, 1e-6))); + for (k, line) in out.iter().enumerate() { + let middle = arc.eval((k as f64 + 0.5) / out.len() as f64); + assert!(middle.distance(line.p0.midpoint(line.p3)) <= tol, "line {k} of {}", out.len()); + } + } + + #[test] + fn lines_for_segments_that_need_too_many_samples_are_limited() { + // Each of these 35 segments takes the most lines a segment can have. + let run = run_of(&SubPath::new(arc_anchors(1e9, 0.0, 10.0, 36), false)); + let opts = crate::SimplifyOptions { tolerance: 1e-3, straight_lines: true, ..Default::default() }; + let out = crate::edit::simplify_run_within(&run, &opts, usize::MAX, None); + assert!(out.len() <= crate::edit::MAX_RUN_RESULT + run.len(), "{} lines", out.len()); + assert!(out.iter().all(|s| s.line)); + assert_eq!(out[out.len() - 1].c.p3, run[run.len() - 1].c.p3); + } + + #[test] + fn a_run_past_the_samples_of_one_fit_has_an_anchor_where_the_parts_meet() { + let (r, tol) = (1_000_000.0, 0.0005); + let run = run_of(&SubPath::new(arc_anchors(r, 0.0, 15.0, 24), false)); + let steps = crate::edit::sample_steps(&run[0].c, tol); + let segments = crate::edit::MAX_FIT_SAMPLES / steps; + assert!(segments + 1 < run.len(), "{steps} steps per segment"); + let opts = crate::SimplifyOptions { tolerance: tol, ..Default::default() }; + let out = crate::edit::simplify_run_within(&run, &opts, usize::MAX, None); + let meet = run[segments - 1].c.p3; + assert!(out.iter().any(|s| s.c.p3 == meet), "no anchor at {meet:?}"); + } + + #[test] + fn segments_past_the_sample_budget_stay_as_they_are() { + // Every segment takes the same steps, so the budget covers the first ten. + let sp = SubPath::new(arc_anchors(100.0, 0.0, 15.0, 20), false); + let run = run_of(&sp); + let opts = crate::SimplifyOptions { tolerance: 1.0, ..Default::default() }; + let steps = crate::edit::sample_steps(&run[0].c, 1.0); + let out = crate::edit::simplify_run_within(&run, &opts, steps * 10, None); + assert!(out.len() < 19, "{} segments", out.len()); + let tail: Vec = out[out.len() - 9..].iter().map(|s| s.c).collect(); + let want: Vec = run[10..].iter().map(|s| s.c).collect(); + assert_eq!(tail, want); + } + + #[test] + fn a_curve_that_needs_too_many_checks_is_rejected() { + // The first curve goes along the line and back. The second one goes along it evenly. + let pts = [Point::new(0.0, 0.0), Point::new(1e9, 0.0)]; + let c = CubicBez::new(pts[0], pts[1], pts[0], pts[1]); + assert_eq!(bulge(&pts, &c, &[0.0, 1.0], 1e-3), Some(0)); + let c = CubicBez::new(pts[0], Point::new(1e9 / 3.0, 0.0), Point::new(2e9 / 3.0, 0.0), pts[1]); + assert_eq!(bulge(&pts, &c, &[0.0, 1.0], 1e-3), None); + } + + /// `rows` rows of `cols` smooth anchors 1.6 pt apart, alternately 2 pt up. + fn serpentine(rows: usize, cols: usize, closed: bool) -> SubPath { + let mut anchors = Vec::with_capacity(rows * cols); + for row in 0..rows { + let dir = if row % 2 == 0 { 1.0 } else { -1.0 }; + for col in 0..cols { + let x = if row % 2 == 0 { col } else { cols - 1 - col }; + let p = Point::new(x as f64 * 1.6, row as f64 * 6.0 + if col % 2 == 0 { 0.0 } else { 2.0 }); + anchors.push(Anchor::smooth(p, p + Vec2::new(0.5 * dir, 0.0))); + } + } + SubPath::new(anchors, closed) + } + + /// `n` straight segments of `step` pt that turn by `degrees` left and right in turn. + fn zigzag(n: usize, degrees: f64, step: f64, closed: bool) -> SubPath { + let half = (degrees / 2.0).to_radians(); + let mut p = Point::ZERO; + let mut pts = Vec::with_capacity(n); + for i in 0..n { + pts.push(p); + let a = if i % 2 == 0 { half } else { -half }; + p += Vec2::new(a.cos(), a.sin()) * step; + } + SubPath::polyline(&pts, closed) + } + + /// Smooth anchors along arcs that bend one way and then the other, 40 anchors per arc. + fn alternating_arcs(arcs: usize) -> SubPath { + let mut anchors = Vec::with_capacity(arcs * 40); + let (mut p, mut heading) = (Point::ZERO, 0.0_f64); + for arc in 0..arcs { + for _ in 0..40 { + heading += if arc % 2 == 0 { 0.08 } else { -0.08 }; + let d = Vec2::new(heading.cos(), heading.sin()) * 2.0; + anchors.push(Anchor::smooth(p, p + d / 3.0)); + p += d; + } + } + SubPath::new(anchors, false) + } + + /// A polyline of random steps, with some points repeated when `repeats` is set. + fn random_walk(rnd: &mut impl FnMut() -> f64, n: usize, repeats: bool, closed: bool) -> SubPath { + let mut p = Point::new(rnd() * 100.0, rnd() * 100.0); + let mut pts = Vec::with_capacity(n); + for _ in 0..n { + pts.push(p); + if repeats && rnd() < 0.3 { + pts.push(p); + } + p += Vec2::new(rnd() * 6.0 - 3.0, rnd() * 6.0 - 3.0); + } + SubPath::polyline(&pts, closed) + } + + /// A chain of smooth anchors with random steps and handles. + fn random_curves(rnd: &mut impl FnMut() -> f64, n: usize, closed: bool) -> SubPath { + let mut p = Point::ZERO; + let mut anchors = Vec::with_capacity(n); + for _ in 0..n { + anchors.push(Anchor::smooth(p, p + Vec2::new(1.0 + rnd() * 6.0, rnd() * 8.0 - 4.0))); + p += Vec2::new(5.0 + rnd() * 20.0, rnd() * 30.0 - 15.0); + } + SubPath::new(anchors, closed) + } + + /// `n` smooth anchors 1.6 pt apart, each at a random height of up to 3 pt. + fn random_ripple(rnd: &mut impl FnMut() -> f64, n: usize, closed: bool) -> SubPath { + let anchors = (0..n) + .map(|i| { + let p = Point::new(i as f64 * 1.6, rnd() * 3.0); + Anchor::smooth(p, p + Vec2::new(0.5, 0.0)) + }) + .collect(); + SubPath::new(anchors, closed) + } + + #[test] + fn no_fitted_piece_is_farther_than_the_tolerance() { + for (rows, cols) in [(1, 300), (1, 1000), (5, 400)] { + assert_simplifies_within(&PathData::single(serpentine(rows, cols, false)), 1.0, &format!("serpentine {rows} x {cols}")); + } + assert_simplifies_within(&PathData::single(serpentine(1, 1000, false)), 0.25, "serpentine at 0.25 pt"); + assert_simplifies_within(&PathData::single(alternating_arcs(25)), 0.5, "alternating arcs"); + } + + #[test] + fn a_zigzag_stays_within_the_tolerance() { + // The turns are below the 30 degree corner threshold, so the fit runs across them. + assert_simplifies_within(&PathData::single(zigzag(1500, 20.0, 3.0, false)), 0.5, "zigzag"); + assert_simplifies_within(&PathData::single(zigzag(1500, 29.0, 1.0, false)), 0.1, "fine zigzag"); + // These turns are corners, so every segment is a run of its own. + assert_simplifies_within(&PathData::single(zigzag(500, 60.0, 3.0, false)), 2.0, "zigzag of corners"); + // Simplify samples a segment this long more than `tol` apart, so a curve between two + // samples can leave the path without a sample showing it. + assert_simplifies_within(&PathData::single(zigzag(60, 25.0, 900.0, false)), 1.0, "zigzag of long segments"); + assert_simplifies_within(&PathData::single(zigzag(60, 8.0, 2500.0, true)), 0.5, "closed zigzag of long segments"); + // The short anchors make the result smaller than the path, so Simplify does not return the + // path as it was. + for (step, tol) in [(1000.0, 0.1), (5000.0, 1.0), (20000.0, 1.0)] { + let mut sp = zigzag(40, 25.0, step, false); + let end = sp.anchors[sp.anchors.len() - 1].p; + sp.anchors.extend((1..=2000).map(|i| Anchor::corner(end + Vec2::new(i as f64 * 0.5, 0.0)))); + assert_simplifies_within(&PathData::single(sp), tol, &format!("zigzag of {step} pt segments and short anchors")); + } + } + + #[test] + fn closed_paths_stay_within_the_tolerance() { + assert_simplifies_within(&PathData::single(serpentine(3, 1000, true)), 1.0, "closed serpentine"); + assert_simplifies_within(&PathData::single(zigzag(800, 20.0, 3.0, true)), 0.5, "closed zigzag"); + let circle: Vec = (0..2000) + .map(|i| { + let a = i as f64 / 2000.0 * std::f64::consts::TAU; + let p = Point::new(300.0 * a.cos(), 300.0 * a.sin()); + Anchor::smooth(p, p + Vec2::new(-a.sin(), a.cos()) * 0.3) + }) + .collect(); + assert_simplifies_within(&PathData::single(SubPath::new(circle, true)), 0.05, "closed circle of 2,000 anchors"); + } + + #[test] + fn compound_paths_stay_within_the_tolerance() { + let mut rnd = random(11); + let compound = PathData::new(vec![ + serpentine(1, 1000, false), + zigzag(400, 20.0, 3.0, true), + random_curves(&mut rnd, 100, false), + SubPath::polyline(&[Point::new(0.0, -50.0), Point::new(100.0, -50.0)], false), + ]); + assert_simplifies_within(&compound, 0.75, "compound"); + } + + #[test] + fn coincident_anchors_stay_within_the_tolerance() { + let mut rnd = random(23); + assert_simplifies_within(&PathData::single(random_walk(&mut rnd, 600, true, false)), 1.0, "repeated points"); + assert_simplifies_within(&PathData::single(random_walk(&mut rnd, 600, true, true)), 1.0, "repeated points, closed"); + let p = Point::new(5.0, 5.0); + let same = SubPath::new(vec![Anchor::smooth(p, p + Vec2::new(1.0, 0.0)); 50], false); + assert_simplifies_within(&PathData::single(same), 0.5, "one place"); + let mut doubled = serpentine(1, 1500, false); + let copy = doubled.anchors.clone(); + doubled.anchors = copy.iter().flat_map(|a| [*a, *a]).collect(); + assert_simplifies_within(&PathData::single(doubled), 1.0, "every anchor twice"); + } + + #[test] + fn random_paths_stay_within_the_tolerance() { + let mut rnd = random(0x9e37_79b9_7f4a_7c15); + for case in 0..60 { + let tol = [0.05, 0.25, 1.0, 4.0][case % 4]; + let n = 2 + (rnd() * 300.0) as usize; + let closed = case % 3 == 0; + let sp = if case % 2 == 0 { random_walk(&mut rnd, n, false, closed) } else { random_curves(&mut rnd, n, closed) }; + assert_simplifies_within(&PathData::single(sp), tol, &format!("case {case}")); + } + for (case, tol) in [0.5, 1.0, 2.0].into_iter().enumerate() { + assert_simplifies_within(&PathData::single(random_ripple(&mut rnd, 2000, case == 1)), tol, &format!("ripple at {tol} pt")); + } + } + + #[test] + fn a_curve_stays_within_the_tolerance_between_wide_points() { + let up = Vec2::new(0.6, 0.8); + let down = Vec2::new(0.6, -0.8); + let runs: [&[Point]; 3] = [ + // Two points: the tangents alone shape the curve. + &[Point::new(0.0, 0.0), Point::new(600.0, 0.0)], + // Three points: a curve through the middle one can still bulge on each side of it. + &[Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(600.0, 0.0)], + &[Point::new(0.0, 0.0), Point::new(100.0, 20.0), Point::new(200.0, 0.0), Point::new(300.0, 20.0), Point::new(400.0, 0.0)], + ]; + for pts in runs { + let out = fit_cubics(pts, up, down, 1.0); + let far = samples(&out) + .iter() + .map(|&q| pts.windows(2).map(|w| Line::new(w[0], w[1]).nearest(q, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min)) + .fold(0.0, f64::max); + assert!(far <= 1.0 + 1e-6, "a point of the curve is {far} from the {} points", pts.len()); + } + } + + #[test] + fn a_curve_stays_within_the_tolerance_between_its_checks() { + // A curve with handles this long has its peak between evenly spaced samples. + let pts = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(600.0, 0.0)]; + let tol = 105.47; + let mut dense = Vec::new(); + for c in fit_cubics(&pts, Vec2::new(0.6, 0.8), Vec2::new(0.8, -0.6), tol) { + sample(&c, 4096, &mut dense, true); + } + let far = dense.iter().map(|q| q.y.abs()).fold(0.0, f64::max); + assert!(far <= tol, "a point of the curve is {far} from the line"); + } + + #[test] + fn every_point_is_within_the_tolerance_of_its_chain() { + let mut rnd = random(0x2545_f491_4f6c_dd1d); + for case in 0..400 { + let tol = [0.01, 0.1, 1.0, 5.0][case % 4]; + let mut pts: Vec = Vec::new(); + match case % 5 { + // Open random walk. + 0 => { + let mut p = Point::new(rnd() * 100.0, rnd() * 100.0); + for _ in 0..2 + (rnd() * 400.0) as usize { + pts.push(p); + p += Vec2::new(rnd() * 4.0 - 1.0, rnd() * 4.0 - 2.0); + } + } + // Closed noisy loop: the last point is the first. + 1 => { + let n = 3 + (rnd() * 300.0) as usize; + for i in 0..n { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + let r = 50.0 + rnd() * 3.0; + pts.push(Point::new(r * a.cos(), r * a.sin())); + } + pts.push(pts[0]); + } + // Very short runs. + 2 => { + for _ in 0..2 + (rnd() * 3.0) as usize { + pts.push(Point::new(rnd() * 20.0, rnd() * 20.0)); + } + } + // Coincident points. + 3 => { + let mut p = Point::new(0.0, 0.0); + for _ in 0..2 + (rnd() * 200.0) as usize { + pts.push(p); + if rnd() < 0.3 { + pts.push(p); + } + p += Vec2::new(rnd() * 3.0, rnd() * 6.0 - 3.0); + } + } + // Samples of a chain of wavy cubics, as Simplify takes them. + _ => { + let mut p0 = Point::new(0.0, 0.0); + for k in 0..1 + (rnd() * 60.0) as usize { + let p3 = p0 + Vec2::new(5.0 + rnd() * 10.0, rnd() * 10.0 - 5.0); + let c = CubicBez::new(p0, p0 + Vec2::new(2.0, rnd() * 8.0 - 4.0), p3 - Vec2::new(2.0, rnd() * 8.0 - 4.0), p3); + sample(&c, 4 + (rnd() * 20.0) as usize, &mut pts, k == 0); + p0 = p3; + } + } + } + let t0 = unit(Vec2::new(rnd() - 0.5, rnd() - 0.5)).unwrap_or(Vec2::new(1.0, 0.0)); + let t1 = unit(Vec2::new(rnd() - 0.5, rnd() - 0.5)).unwrap_or(Vec2::new(1.0, 0.0)); + let out = fit_cubics(&pts, t0, t1, tol); + assert_eq!(out.first().map(|c| c.p0), pts.first().copied(), "case {case}"); + assert_eq!(out.last().map(|c| c.p3), pts.last().copied(), "case {case}"); + for w in out.windows(2) { + assert_eq!(w[0].p3, w[1].p0, "case {case}"); + } + let far = farthest(&pts, &out, tol); + assert!(far <= tol + 1e-9, "case {case}: a point is {far} from the fit at a tolerance of {tol}"); + } + // Samples of a long serpentine, which split lopsidedly at their worst points. + let pts = samples(&cubics(&PathData::single(serpentine(1, 1000, false)))); + let out = fit_cubics(&pts, Vec2::new(1.0, 0.0), Vec2::new(1.0, 0.0), 1.0); + let far = farthest(&pts, &out, 1.0); + assert!(far <= 1.0 + 1e-9, "a point of the serpentine is {far} from the fit"); + } + + /// `n` pieces of one segment each along a straight line, and the line's points. + fn straight_pieces(n: usize) -> (Vec, Vec) { + let pts: Vec = (0..=n).map(|i| Point::new(i as f64, 0.0)).collect(); + let t1 = Vec2::new(1.0, 0.0); + let pieces = (1..=n).map(|end| Piece { curve: CubicBez::new(pts[end - 1], pts[end - 1], pts[end], pts[end]), end, t1 }).collect(); + (pts, pieces) + } + + #[test] + fn joined_pieces_make_one_curve_up_to_the_limit() { + let t0 = Vec2::new(1.0, 0.0); + let (pts, pieces) = straight_pieces(MAX_JOINS + 1); + let out = join_pieces(&pts, &pieces, t0, 1.0); + assert_eq!(out.len(), 1); + assert_eq!((out[0].p0, out[0].p3), (pts[0], pts[MAX_JOINS + 1])); + } + + #[test] + fn a_curve_joins_no_more_pieces_than_the_limit() { + let (pts, pieces) = straight_pieces(1000); + let out = join_pieces(&pts, &pieces, Vec2::new(1.0, 0.0), 1.0); + assert_eq!(out.len(), 1000_usize.div_ceil(MAX_JOINS + 1)); + assert_eq!((out[0].p0, out[out.len() - 1].p3), (pts[0], pts[1000])); + for w in out.windows(2) { + assert_eq!(w[0].p3, w[1].p0); + } + } + #[test] fn straight_detection() { let c = CubicBez::new((0.0, 0.0), (3.0, 0.0), (6.0, 0.0), (9.0, 0.0)); diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 3d1b17fef..b7af34c89 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -14,6 +14,7 @@ mod fit; mod offset; mod pathfinder; mod planar; +mod sweep_axis; mod trap; pub use boolean::{ @@ -26,7 +27,7 @@ pub use edit::{ split_into_grid, }; pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path}; -pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions}; +pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions, regions_within}; pub use planar::{ BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap, diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 2766379ef..bf533476b 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -85,6 +85,14 @@ pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_l /// The area covered by stroke outlines (each filled with the non-zero rule, open subpaths /// closed) as one clean path: overlaps removed and the stroker's pieces refitted. pub fn stroke_region(outlines: &[BezPath]) -> PathData { + match crate::sweep_axis::turned_outlines(outlines) { + Some(turned) => crate::sweep_axis::unturn(stroke_region_upright(&turned)), + None => stroke_region_upright(outlines), + } +} + +/// [`stroke_region`] with the sweep run on `outlines` as they are. +pub(crate) fn stroke_region_upright(outlines: &[BezPath]) -> PathData { let tidy = Tidy::free(DEFAULT_PRECISION); let parts: Vec = outlines.iter().map(close_all).filter(|o| !o.elements().is_empty()).collect(); if let [one] = &parts[..] { diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index 8f0fb163e..ee9db37f2 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -2,6 +2,7 @@ //! (index 0 = back-most, last = front-most). use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; use kurbo::{BezPath, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Shape as _}; use linesweeper::topology::{ContourIdx, Contours}; @@ -12,6 +13,12 @@ use crate::boolean::{ to_seg, unite_all, unite_all_with, }; +#[cfg(test)] +thread_local! { + /// The face outlines [`regions_of_within`] has built on this thread. + static FACE_PATHS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour); /// results carry the key of the object whose paint they keep. #[derive(Clone, Debug, PartialEq)] @@ -221,19 +228,40 @@ pub fn pathfinder_with(op: PathfinderOp, shapes: &[Shape], precision: f64) -> Ve /// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face an outer contour /// and the holes of its mask directly inside it ([`face_groups`]). fn regions_of(arr: &Arrangement) -> Vec { + regions_of_within(arr, usize::MAX).unwrap_or_default() +} + +/// [`regions_of`] while there are at most `limit` faces: `None` as soon as one more is found, +/// without building the faces after it. +fn regions_of_within(arr: &Arrangement, limit: usize) -> Option> { let masks = arr.distinct_masks(); let refs: Vec<&[usize]> = masks.iter().map(Vec::as_slice).collect(); let mut faces: Vec> = vec![Vec::new(); masks.len()]; - each_face(arr, &refs, |m, c, group| { - if let Some(f) = faces.get_mut(m) { - f.push(contours_to_path(c, group.iter().copied(), &arr.tidy)); + let mut found = 0usize; + let walk = each_face_until(arr, &refs, |m, c, group| { + #[cfg(test)] + FACE_PATHS.with(|n| n.set(n.get() + 1)); + let path = contours_to_path(c, group.iter().copied(), &arr.tidy); + if path.is_empty() { + return ControlFlow::Continue(()); } + found += 1; + if found > limit { + return ControlFlow::Break(()); + } + if let Some(f) = faces.get_mut(m) { + f.push(path); + } + ControlFlow::Continue(()) }); + if walk.is_break() { + return None; + } let mut out = Vec::new(); for (sources, paths) in masks.iter().zip(faces) { out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() })); } - out + Some(out) } /// Calls `f` with each face of `arr` classified by `classify: impl Fn(&Multi) -> Option` @@ -250,8 +278,21 @@ fn each_classified_face( classify: impl Fn(&Multi) -> Option, mut f: impl FnMut(usize, &Contours, &[ContourIdx]), ) { + let _ = each_classified_face_until(arr, num_classes, classify, |m, c, group| { + f(m, c, group); + ControlFlow::Continue(()) + }); +} + +/// [`each_classified_face`] until `f` breaks: no face after that one is walked. +fn each_classified_face_until( + arr: &Arrangement, + num_classes: usize, + classify: impl Fn(&Multi) -> Option, + mut f: impl FnMut(usize, &Contours, &[ContourIdx]) -> ControlFlow<()>, +) -> ControlFlow<()> { if num_classes == 0 { - return; + return ControlFlow::Continue(()); } let top = &arr.top; // The classes of the faces around each vertex. @@ -303,15 +344,20 @@ fn each_classified_face( let c = arr.contours_where(|w| classify(w) == Some(m)); let owner = vec![m; c.contours().count()]; for (_, group) in face_groups(&c, &owner) { - f(m, &c, &group); + if f(m, &c, &group).is_break() { + return ControlFlow::Break(()); + } } } continue; }; for (m, group) in face_groups(&c, &owner) { - f(m, &c, &group); + if f(m, &c, &group).is_break() { + return ControlFlow::Break(()); + } } } + ControlFlow::Continue(()) } /// Calls `f` with each face of `arr` whose coverage is one of `masks` (distinct, each the inputs @@ -325,13 +371,21 @@ fn each_classified_face( /// share no vertex (so no edge either): one pass per batch walks each face's contours exactly as /// its own mask's pass would, and the mask a contour bounds is the one of its batch at its first /// point. -fn each_face(arr: &Arrangement, masks: &[&[usize]], f: impl FnMut(usize, &Contours, &[ContourIdx])) { +fn each_face(arr: &Arrangement, masks: &[&[usize]], mut f: impl FnMut(usize, &Contours, &[ContourIdx])) { + let _ = each_face_until(arr, masks, |m, c, group| { + f(m, c, group); + ControlFlow::Continue(()) + }); +} + +/// [`each_face`] until `f` breaks: no face after that one is walked. +fn each_face_until(arr: &Arrangement, masks: &[&[usize]], f: impl FnMut(usize, &Contours, &[ContourIdx]) -> ControlFlow<()>) -> ControlFlow<()> { if masks.is_empty() { - return; + return ControlFlow::Continue(()); } // Each mask is found by the inputs it covers. let ids = CoverIds::new(masks.iter().map(|m| m.to_vec()).collect()); - each_classified_face(arr, masks.len(), |w| ids.of(arr, w), f); + each_classified_face_until(arr, masks.len(), |w| ids.of(arr, w), f) } /// Lists of inputs, each ascending as [`Arrangement::covered`] gives them, found by the inputs @@ -463,6 +517,15 @@ pub(crate) fn regions_with(shapes: &[Shape], precision: f64) -> Vec { arrangement(shapes, precision).map(|a| regions_of(&a)).unwrap_or_default() } +/// [`regions`] when there are at most `limit` of them; `None` when there are more, found as soon +/// as the face past `limit` is built (the faces after it are not built). +pub fn regions_within(shapes: &[Shape], limit: usize) -> Option> { + match arrangement(shapes, DEFAULT_PRECISION) { + Some(a) => regions_of_within(&a, limit), + None => Some(Vec::new()), + } +} + /// The face under `point`, if any (Shape Builder hover/click). pub fn region_at(shapes: &[Shape], point: Point) -> Option { regions(shapes).into_iter().find(|r| r.contains(point)) @@ -1317,6 +1380,29 @@ mod tests { assert!((area - 490.0 * 40.0).abs() < 1e-6, "{area}"); } + /// The row of 49 rectangles makes 97 faces: within a limit of 97 or more they are [`regions`]'s + /// faces, and over a lower one the build stops at the face past it. + #[test] + fn regions_within_a_limit_stop_at_the_face_past_it() { + let v: Vec = (0..49) + .map(|k| { + let x = 10.0 * f64::from(k); + Shape::new(rect(x, 0.0, x + 15.0, 40.0), FillRule::NonZero, 1) + }) + .collect(); + let all = regions(&v); + assert_eq!(all.len(), 97); + for limit in [97, 98, usize::MAX] { + assert_eq!(regions_within(&v, limit).as_ref(), Some(&all), "limit {limit}"); + } + for limit in [0, 1, 10, 96] { + FACE_PATHS.with(|n| n.set(0)); + assert_eq!(regions_within(&v, limit), None, "limit {limit}"); + assert_eq!(FACE_PATHS.with(std::cell::Cell::get), limit + 1, "limit {limit}: faces built"); + } + assert_eq!(regions_within(&[], 0), Some(vec![])); + } + /// The lists of inputs a winding covers are found by those inputs, also among lists that share /// a hash: here every list is filed under every hash. #[test] diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index 02e8cc346..75234b394 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -34,7 +34,7 @@ mod shape_builder; pub(crate) use graph::pin_ends; #[cfg(test)] -use index::{Crossings, RectIndex, reference_budget, segment_footprint}; +use index::{Crossings, RectIndex, reference_budget, segment_footprint, work_done}; pub use live_paint::{find_gaps, live_paint, live_paint_with_gaps}; pub use map::interior_point; pub(crate) use map::{Map, Rest, map}; diff --git a/crates/pathops/src/planar/index.rs b/crates/pathops/src/planar/index.rs index 1b59f829c..3c7d9f9be 100644 --- a/crates/pathops/src/planar/index.rs +++ b/crates/pathops/src/planar/index.rs @@ -61,6 +61,25 @@ impl WindingNumber for Crossings { } } +#[cfg(test)] +thread_local! { + /// [`work_done`] of this thread. + static WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many items [`RectIndex::at`] has read and segments [`run_around`](super::run_around) has +/// searched for the nearest point on a whole piece, on this thread. +#[cfg(test)] +pub(super) fn work_done() -> u64 { + WORK.get() +} + +/// Adds `n` to [`work_done`]. +#[cfg(test)] +pub(super) fn note_work(n: usize) { + WORK.set(WORK.get().saturating_add(n as u64)); +} + /// A point in the middle of a run of segments. pub(super) fn mid(segs: &[Seg]) -> Option { segs.get(segs.len() / 2).map(|s| s.c.eval(0.5)) @@ -200,6 +219,8 @@ impl RectIndex { } } } + #[cfg(test)] + note_work(found.len()); found.sort_unstable(); found.dedup(); found.into_iter().filter_map(move |i| self.items.get(i)).filter(move |(r, _)| r.inflate(tol, tol).contains(p)).map(|(_, t)| t) diff --git a/crates/pathops/src/planar/shape_builder.rs b/crates/pathops/src/planar/shape_builder.rs index fde156f9b..fcfb6cea9 100644 --- a/crates/pathops/src/planar/shape_builder.rs +++ b/crates/pathops/src/planar/shape_builder.rs @@ -274,6 +274,8 @@ pub fn run_around(piece: &SubPath, stops: &[Point], at: Point, tol: f64) -> Opti /// The point of `sp` nearest `p`, as a parameter `segment + t`, with its distance. fn nearest_u(sp: &SubPath, p: Point) -> Option<(f64, f64)> { use kurbo::ParamCurveNearest; + #[cfg(test)] + super::index::note_work(sp.segment_count()); (0..sp.segment_count()) .map(|k| { let n = sp.segment(k).nearest(p, 1e-9); diff --git a/crates/pathops/src/planar/tests.rs b/crates/pathops/src/planar/tests.rs index 3cee370df..f9b88a27f 100644 --- a/crates/pathops/src/planar/tests.rs +++ b/crates/pathops/src/planar/tests.rs @@ -606,18 +606,23 @@ fn shape_builder_stays_quick_up_to_its_cap() { .collect(); let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)]; // Closed paths alone are swept for the gaps they enclose. - let started = std::time::Instant::now(); + let started = work_done(); assert_eq!(shape_builder(&s, false).regions.len(), 1, "the ring encloses no gap"); - assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + // Reads of indexed items stay in proportion to the ring. Scanning the ring for each edge's + // source would read about `n` items per edge. + let most = 64 * n as u64; + let work = work_done() - started; + assert!(work <= most, "{work} reads"); for k in 0..4 { let y = -60.0 + 40.0 * f64::from(k); s.push(line((-150.0, y), (150.0, y + 7.0), 1)); } - let started = std::time::Instant::now(); + let started = work_done(); let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); assert!(regions.len() >= 5, "the ring cut in five bands at least (the waves make pockets)"); assert!(lines.len() >= 12, "{}", lines.len()); - assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + let work = work_done() - started; + assert!(work <= most, "{work} reads"); // Past the cap, only the filled areas count. s.push(line((0.0, 0.0), (1.0, 1.0), 2)); s[0].path.subpaths[0].anchors.extend((0..16).map(|k| vectorcraft_geom::Anchor::corner(Point::new(100.0, f64::from(k))))); @@ -733,6 +738,19 @@ fn rect_index_reads_a_diagonal_hatch_only_near_the_point() { assert_eq!(index.at(Point::new(-5.0, 0.0), 0.1).count(), 0); } +#[test] +fn rect_index_counts_each_entry_it_reads() { + // Ten collinear lines filed under each of the 10 cells along them: a lookup 300 pt either side + // reads every line from each of the cells it spans, more than once per line. + let line = PathSeg::Line(kurbo::Line::new((0.0, 0.0), (1000.0, 0.0))); + let items: Vec<_> = (0..10).map(|k| (line.bounding_box(), (line, k))).collect(); + let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell)); + let started = work_done(); + assert_eq!(index.at(Point::new(500.0, 0.0), 300.0).count(), 10); + let work = work_done() - started; + assert!(work >= 50, "{work} reads"); +} + /// The grid's memory stays linear in the item count: pieces of one segment falling in the same /// cell file it once, and coincident segments (each along every cell of a one-column grid) go /// to the scanned list once the grid holds its budget of references, and are still all found. @@ -966,12 +984,13 @@ fn run_around_a_ring_of_4000_sides_measures_only_the_stops_it_reaches() { .collect(); let ring = poly(&pts, true); let stops: Vec = ring.anchors.iter().map(|a| a.p).collect(); - let start = std::time::Instant::now(); + let started = work_done(); for k in (0..n).step_by(200) { let run = run_around(&ring, &stops, ring.segment(k).eval(0.5), 1e-3).expect("a side"); assert_eq!(ends(&run), (stops[k], stops[(k + 1) % n]), "side {k}"); } - assert!(start.elapsed().as_secs_f64() < 5.0, "20 runs took {:?}", start.elapsed()); + let work = work_done() - started; + assert!(work <= 1_000_000, "20 runs made {work} searches"); } /// Proptest shrank a failure of `pieces_cut_out_of_their_inputs` to this input with linesweeper @@ -1095,10 +1114,11 @@ fn many_lines_stay_quick() { line((50.0 + 80.0 * t.cos(), 50.0 + 80.0 * t.sin()), (50.0 - 80.0 * (t + 1.1).cos(), 50.0 - 80.0 * (t + 1.1).sin()), 0) }) .collect(); - let started = std::time::Instant::now(); + let started = work_done(); let (faces, _) = live_paint(&lines); assert!(faces.len() > 500, "{}", faces.len()); - assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + let work = work_done() - started; + assert!(work <= 150_000, "{work} reads"); } #[test] diff --git a/crates/pathops/src/sweep_axis.rs b/crates/pathops/src/sweep_axis.rs new file mode 100644 index 000000000..364325afc --- /dev/null +++ b/crates/pathops/src/sweep_axis.rs @@ -0,0 +1,393 @@ +//! Which way the overlap sweep runs. `linesweeper` moves a horizontal line down the page, and the +//! time it spends at each event grows with the number of segments on that line at the time. A long, +//! low path (rows of a zigzag, a stroked line of type) can put thousands of segments on the line at +//! once, where the same path turned a quarter turn puts far fewer on it. [`turned_outlines`] and +//! [`turned_fills`] estimate both directions before the sweep; when the turned one is clearly +//! cheaper they return the input turned, the caller runs its operation on that, and [`unturn`] +//! turns the result back. A quarter turn swaps and negates coordinates, so no point moves by +//! rounding. + +use kurbo::{Affine, BezPath, PathSeg, Rect}; +use vectorcraft_geom::{Anchor, FillRule, PathData, SubPath}; + +/// Inputs with fewer segments than this are always swept as they are. +const MIN_SEGMENTS: usize = 2048; + +/// The turned direction is used when its estimate is below the upright one divided by this. +const MIN_GAIN: f64 = 1.5; + +/// The most segments the estimate measures. +const MAX_SAMPLE: usize = 4096; + +/// (x, y) to (−y, x). +const TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); + +/// (x, y) to (y, −x): the inverse of [`TURN`]. +const UNTURN: Affine = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); + +#[cfg(test)] +thread_local! { + /// The inputs turned on this thread. + pub(crate) static TURNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// For each of the spans from `starts[i]` to `ends[i]`, the number of spans that overlap it +/// (touching counts, itself included), summed: the segments a sweep across these extents has on +/// its line while each one is there. Sorts both lists. +fn load(starts: &mut [f64], ends: &mut [f64]) -> u64 { + starts.sort_unstable_by(f64::total_cmp); + ends.sort_unstable_by(f64::total_cmp); + // Spans that start at or before each end, less spans that end before each start: every span + // that ends before a start also starts before it, so it was counted in the first sum. + let (mut begun, mut i) = (0u64, 0usize); + for &e in ends.iter() { + while starts.get(i).is_some_and(|&s| s <= e) { + i += 1; + } + begun += i as u64; + } + let (mut over, mut j) = (0u64, 0usize); + for &s in starts.iter() { + while ends.get(j).is_some_and(|&e| e < s) { + j += 1; + } + over += j as u64; + } + begun.saturating_sub(over) +} + +/// The segments of `outlines`. +fn outline_segments(outlines: &[BezPath]) -> impl Iterator + '_ { + outlines.iter().flat_map(BezPath::segments) +} + +/// The box of a segment's control points. +fn segment_box(s: PathSeg) -> Rect { + match s { + PathSeg::Line(l) => Rect::from_points(l.p0, l.p1), + PathSeg::Quad(q) => Rect::from_points(q.p0, q.p2).union_pt(q.p1), + PathSeg::Cubic(c) => Rect::from_points(c.p0, c.p3).union_pt(c.p1).union_pt(c.p2), + } +} + +/// The subpaths of `paths` that have a fill: those of two or more anchors. +fn filled_subpaths<'a>(paths: &'a [(&PathData, FillRule)]) -> impl Iterator + 'a { + paths.iter().flat_map(|(p, _)| &p.subpaths).filter(|sp| sp.anchors.len() >= 2) +} + +/// The start and end anchor of each segment in the fills of `paths`, every subpath of two or more +/// anchors closed, as [`fill_bezpath`](crate::boolean::fill_bezpath) builds them. +fn fill_segments<'a>(paths: &'a [(&PathData, FillRule)]) -> impl Iterator + 'a { + filled_subpaths(paths).flat_map(|sp| { + let n = sp.anchors.len(); + sp.anchors.iter().enumerate().map(move |(i, a)| (a, &sp.anchors[(i + 1) % n])) + }) +} + +/// The box of the control points of the fill segment from `a` to `b`. +fn fill_segment_box((a, b): (&Anchor, &Anchor)) -> Rect { + Rect::from_points(a.p, b.p).union_pt(a.h_out).union_pt(b.h_in) +} + +/// Is the sweep over the `count` `segments`, each with the box `bbox` gives it, clearly cheaper +/// turned a quarter turn? Past [`MAX_SAMPLE`] segments, one segment from each run of +/// `count / MAX_SAMPLE` (rounded up) stands for the run, at a position within it that changes from +/// run to run, and only those segments' boxes are built. +fn turn_pays(count: usize, segments: impl Iterator, bbox: impl Fn(T) -> Rect) -> bool { + if count < MIN_SEGMENTS { + return false; + } + let stride = count.div_ceil(MAX_SAMPLE); + let mut pick = 0; + let boxes: Vec = segments + .enumerate() + .filter(|&(i, _)| { + if i % stride == 0 { + pick = sample_offset(i / stride, stride); + } + i % stride == pick + }) + .map(|(_, s)| bbox(s)) + .collect(); + if !boxes.iter().all(Rect::is_finite) { + return false; + } + let (mut x0, mut x1): (Vec, Vec) = boxes.iter().map(|b| (b.x0, b.x1)).unzip(); + let (mut y0, mut y1): (Vec, Vec) = boxes.iter().map(|b| (b.y0, b.y1)).unzip(); + let pays = load(&mut x0, &mut x1) as f64 * MIN_GAIN < load(&mut y0, &mut y1) as f64; + #[cfg(test)] + if pays { + TURNS.with(|n| n.set(n.get() + 1)); + } + pays +} + +/// The position, below `stride`, of the segment measured in run `run`: a hash of `run` (the +/// SplitMix64 finalizer), so input that repeats every few segments is measured at every position. +fn sample_offset(run: usize, stride: usize) -> usize { + let mut z = (run as u64).wrapping_add(0x9e37_79b9_7f4a_7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + ((z ^ (z >> 31)) % stride as u64) as usize +} + +/// `outlines` turned a quarter turn, when sweeping them that way is clearly cheaper. +pub(crate) fn turned_outlines(outlines: &[BezPath]) -> Option> { + let count = outlines.iter().map(|o| o.segments().count()).sum(); + turn_pays(count, outline_segments(outlines), segment_box).then(|| outlines.iter().map(|o| TURN * o).collect()) +} + +/// `paths` turned a quarter turn, when sweeping their fills that way is clearly cheaper. +pub(crate) fn turned_fills(paths: &[(&PathData, FillRule)]) -> Option> { + let count = filled_subpaths(paths).map(|sp| sp.anchors.len()).sum(); + turn_pays(count, fill_segments(paths), fill_segment_box).then(|| paths.iter().map(|(p, r)| (p.transformed(TURN), *r)).collect()) +} + +/// A result computed on turned input, turned back. +pub(crate) fn unturn(mut path: PathData) -> PathData { + path.transform(UNTURN); + path +} + +#[cfg(test)] +mod tests { + use kurbo::{Join, Stroke, StrokeOpts}; + use vectorcraft_geom::{Anchor, Point, Vec2}; + + use super::*; + use crate::boolean::{BoolOp, DEFAULT_PRECISION, fill_bezpath, try_area, try_boolean, try_unite_all_upright, unite_all_upright}; + use crate::offset::stroke_region_upright; + use crate::{stroke_region, try_unite_all_with, unite_all_with}; + + /// A small xorshift generator, so the random cases are the same on every run. + struct Rng(u64); + + impl Rng { + fn unit(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + } + + /// `rows` rows of a zigzag `cols` anchors long, each anchor moved by up to `jitter`: a long, low path. + fn zigzag(rows: usize, cols: usize, jitter: f64, rng: &mut Rng, closed: bool) -> PathData { + let mut anchors = Vec::new(); + for row in 0..rows { + for col in 0..cols { + let c = if row % 2 == 0 { col } else { cols - 1 - col }; + let x = c as f64 * 1.6 + (rng.unit() - 0.5) * jitter; + let y = row as f64 * 6.0 + if col % 2 == 0 { 0.0 } else { 2.0 } + (rng.unit() - 0.5) * jitter; + let p = Point::new(x, y); + let dir = if row % 2 == 0 { 0.5 } else { -0.5 }; + anchors.push(Anchor::smooth(p, p + Vec2::new(dir, 0.0))); + } + } + PathData::single(SubPath::new(anchors, closed)) + } + + /// The same zigzag stood on end: a tall, narrow path. + fn upright(p: &PathData) -> PathData { + p.transformed(Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0])) + } + + /// A unit square with its lower left corner at (`x`, `y`). + fn square(x: f64, y: f64) -> PathData { + let corners = [(x, y), (x + 1.0, y), (x + 1.0, y + 1.0), (x, y + 1.0)]; + PathData::single(SubPath::new(corners.map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), true)) + } + + fn outline(p: &PathData) -> BezPath { + kurbo::stroke(p.to_bezpath().iter(), &Stroke::new(1.0).with_join(Join::Round), &StrokeOpts::default(), 1e-3) + } + + fn segments(p: &BezPath) -> usize { + p.segments().count() + } + + fn turns_during(f: impl FnOnce()) -> usize { + let before = TURNS.with(std::cell::Cell::get); + f(); + TURNS.with(std::cell::Cell::get) - before + } + + /// `a` and `b` have the same pieces and cover the same area to within the refit precision: the + /// area in one and not the other is less than a band [`DEFAULT_PRECISION`] wide along the outline. + fn assert_same_region(a: &PathData, b: &PathData, what: &str) { + use kurbo::Shape; + assert_eq!(a.subpaths.len(), b.subpaths.len(), "{what}: subpaths"); + let band = DEFAULT_PRECISION * a.to_bezpath().perimeter(1e-3); + let area_a = try_area(a, FillRule::NonZero).unwrap(); + let area_b = try_area(b, FillRule::NonZero).unwrap(); + assert!((area_a - area_b).abs() <= band, "{what}: area {area_a} against {area_b}, band {band}"); + let apart = try_boolean(a, FillRule::NonZero, b, FillRule::NonZero, BoolOp::Xor, DEFAULT_PRECISION).unwrap(); + let apart = try_area(&apart, FillRule::NonZero).unwrap(); + assert!(apart <= band, "{what}: {apart} of {area_a} differs, band {band}"); + } + + #[test] + fn a_long_low_path_is_swept_turned_and_a_tall_or_small_one_as_it_is() { + let mut rng = Rng(7); + let low = zigzag(3, 300, 0.0, &mut rng, false); + let stroke = outline(&low); + // More segments than the estimate measures, so it decides from a sample. + assert!(segments(&stroke) > MAX_SAMPLE, "{} segments", segments(&stroke)); + assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&stroke)))), 1); + let fill = zigzag(3, 800, 0.0, &mut rng, true); + assert_eq!(turns_during(|| drop(unite_all_with(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION))), 1); + assert_eq!(turns_during(|| drop(try_unite_all_with(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION))), 1); + // Stood on end, the same paths are already narrow across the sweep. + let tall = outline(&upright(&low)); + assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&tall)))), 0); + assert_eq!(turns_during(|| drop(unite_all_with(&[(&upright(&fill), FillRule::NonZero)], DEFAULT_PRECISION))), 0); + // A square grid costs the same either way. + let grid: Vec = (0..900).map(|i| square((i % 30) as f64 * 3.0, (i / 30) as f64 * 3.0)).collect(); + let grid: Vec<(&PathData, FillRule)> = grid.iter().map(|p| (p, FillRule::NonZero)).collect(); + assert_eq!(turns_during(|| drop(unite_all_with(&grid, DEFAULT_PRECISION))), 0); + // Below the size where the sweep costs much, nothing is turned. + let short = outline(&zigzag(1, 40, 0.0, &mut rng, false)); + assert!(segments(&short) < MIN_SEGMENTS); + assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&short)))), 0); + } + + #[test] + fn a_repeating_path_is_measured_at_every_position() { + // Short segments in a row along the bottom, with a long one stacked above the row after + // every `period - 1` of them. The short ones alone are cheaper to sweep turned; with the + // long ones, the path is cheaper upright. + let add = |path: &mut BezPath, a: (f64, f64), b: (f64, f64)| { + path.move_to(a); + path.line_to(b); + }; + for period in [2, 4] { + let n = MAX_SAMPLE; + let width = (n * period) as f64 * 3.0; + let mut both = BezPath::new(); + let mut shorts = BezPath::new(); + for i in 0..n { + for k in 0..period - 1 { + let x = (i * period + k) as f64 * 3.0; + add(&mut both, (x, 0.0), (x + 1.0, 0.0)); + add(&mut shorts, (x, 0.0), (x + 1.0, 0.0)); + } + add(&mut both, (0.0, 10.0 + i as f64), (width, 10.0 + i as f64)); + } + assert_eq!(segments(&both), period * MAX_SAMPLE, "period {period}"); + assert!(turned_outlines(&[shorts]).is_some(), "period {period}: shorts"); + assert!(turned_outlines(&[both]).is_none(), "period {period}: both"); + } + } + + #[test] + fn the_load_counts_every_overlapping_pair() { + let mut rng = Rng(5); + for n in [0, 1, 2, 7, 60] { + // Coarse values, so many spans share ends and touch. + let spans: Vec<(f64, f64)> = (0..n) + .map(|_| { + let a = (rng.unit() * 10.0).round(); + let b = (rng.unit() * 10.0).round(); + (a.min(b), a.max(b)) + }) + .collect(); + let by_hand = spans.iter().map(|&(lo, hi)| spans.iter().filter(|&&(s, e)| s <= hi && e >= lo).count() as u64).sum::(); + let (mut starts, mut ends): (Vec, Vec) = spans.iter().copied().unzip(); + assert_eq!(load(&mut starts, &mut ends), by_hand, "{n} spans"); + } + } + + #[test] + fn fill_boxes_are_the_boxes_of_the_fills_segments() { + let mut rng = Rng(9); + let open = zigzag(2, 30, 1.0, &mut rng, false); + let closed = zigzag(1, 20, 1.0, &mut rng, true); + let lone = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(4.0, 4.0))], false)); + let paths = [(&open, FillRule::NonZero), (&closed, FillRule::EvenOdd), (&lone, FillRule::NonZero)]; + let fills: Vec = paths.iter().map(|(p, _)| fill_bezpath(p)).collect(); + assert_eq!(fill_segments(&paths).map(fill_segment_box).collect::>(), outline_segments(&fills).map(segment_box).collect::>()); + assert_eq!(filled_subpaths(&paths).map(|sp| sp.anchors.len()).sum::(), fills.iter().map(|f| f.segments().count()).sum::()); + } + + #[test] + fn turning_and_turning_back_moves_no_point() { + let mut rng = Rng(11); + let p = zigzag(2, 50, 3.0, &mut rng, true); + assert_eq!(unturn(p.transformed(TURN)), p); + let bp = outline(&p); + assert_eq!(UNTURN * (TURN * &bp), bp); + } + + #[test] + fn turned_results_match_the_upright_sweep_on_random_paths() { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + for case in 0..3 { + let rows = 2 + case; + let low = zigzag(rows, 120 + 40 * case, 0.8, &mut rng, false); + let stroke = [outline(&low)]; + assert!(turned_outlines(&stroke).is_some(), "case {case}: not turned"); + assert_same_region(&stroke_region(&stroke), &stroke_region_upright(&stroke), &format!("case {case}: stroke")); + let fill = zigzag(rows, 1100, 0.8, &mut rng, true); + let one = [(&fill, FillRule::NonZero)]; + assert!(turned_fills(&one).is_some(), "case {case}: fill not turned"); + assert_same_region(&unite_all_with(&one, DEFAULT_PRECISION), &unite_all_upright(&one, DEFAULT_PRECISION), &format!("case {case}: unite")); + assert_same_region( + &try_unite_all_with(&one, DEFAULT_PRECISION).unwrap(), + &try_unite_all_upright(&one, DEFAULT_PRECISION).unwrap(), + &format!("case {case}: try_unite"), + ); + } + } + + #[test] + fn tall_and_ordinary_art_give_exactly_the_upright_result() { + let mut rng = Rng(13); + let tall = [outline(&upright(&zigzag(3, 300, 0.4, &mut rng, false)))]; + assert_eq!(stroke_region(&tall), stroke_region_upright(&tall), "tall stroke"); + let tall_fill = upright(&zigzag(3, 800, 0.4, &mut rng, true)); + let one = [(&tall_fill, FillRule::NonZero)]; + assert_eq!(unite_all_with(&one, DEFAULT_PRECISION), unite_all_upright(&one, DEFAULT_PRECISION), "tall unite"); + // A square grid of overlapping squares, and a small path below the size that is measured. + let grid: Vec = (0..900).map(|i| square((i % 30) as f64 * 0.75, (i / 30) as f64 * 0.75)).collect(); + let grid: Vec<(&PathData, FillRule)> = grid.iter().map(|p| (p, FillRule::NonZero)).collect(); + assert_eq!(unite_all_with(&grid, DEFAULT_PRECISION), unite_all_upright(&grid, DEFAULT_PRECISION), "grid"); + assert_eq!(try_unite_all_with(&grid, DEFAULT_PRECISION).unwrap(), try_unite_all_upright(&grid, DEFAULT_PRECISION).unwrap(), "grid, try"); + let small = [outline(&zigzag(1, 40, 0.4, &mut rng, false))]; + assert_eq!(stroke_region(&small), stroke_region_upright(&small), "small"); + } + + #[test] + fn edge_cases_give_the_upright_result() { + // Nothing to sweep. + assert!(turned_outlines(&[]).is_none()); + assert!(stroke_region(&[]).is_empty()); + assert!(unite_all_with(&[], DEFAULT_PRECISION).is_empty()); + // A single segment. + let line = BezPath::from_vec(vec![kurbo::PathEl::MoveTo((0.0, 0.0).into()), kurbo::PathEl::LineTo((5_000.0, 0.0).into())]); + let one = [outline(&PathData::from_bezpath(&line))]; + assert!(turned_outlines(&one).is_none()); + assert_same_region(&stroke_region(&one), &stroke_region_upright(&one), "one segment"); + + // No overlaps: a row of separate squares. + let squares: Vec = (0..600).map(|i| square(i as f64 * 3.0, 0.0)).collect(); + let refs: Vec<(&PathData, FillRule)> = squares.iter().map(|p| (p, FillRule::NonZero)).collect(); + assert!(turned_fills(&refs).is_some()); + let apart = unite_all_with(&refs, DEFAULT_PRECISION); + assert_eq!(apart.subpaths.len(), 600); + assert_same_region(&apart, &unite_all_upright(&refs, DEFAULT_PRECISION), "separate squares"); + + // All anchors on one line: no area either way. + let flat = PathData::single(SubPath::new((0..2100).map(|i| Anchor::corner(Point::new(i as f64, 0.0))).collect(), true)); + let flat = [(&flat, FillRule::NonZero)]; + assert!(turned_fills(&flat).is_some()); + assert!(unite_all_with(&flat, DEFAULT_PRECISION).is_empty()); + assert!(unite_all_upright(&flat, DEFAULT_PRECISION).is_empty()); + + // Coincident segments: the same path twice unites to that path. + let mut rng = Rng(3); + let fill = zigzag(2, 1100, 0.0, &mut rng, true); + let twice = [(&fill, FillRule::NonZero), (&fill, FillRule::NonZero)]; + assert!(turned_fills(&twice).is_some()); + assert_same_region(&unite_all_with(&twice, DEFAULT_PRECISION), &unite_all_upright(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION), "twice"); + } +} diff --git a/crates/pdf/src/marks.rs b/crates/pdf/src/marks.rs index dc4575f26..37fc9f6c8 100644 --- a/crates/pdf/src/marks.rs +++ b/crates/pdf/src/marks.rs @@ -4,7 +4,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::marks::{MarkStyle, PrinterMarks, outset}; -use vectorcraft_doc::setup::MAX_BLEED; +use vectorcraft_doc::setup::{MAX_BLEED, bleed_in_range}; use vectorcraft_doc::{Document, Node, NodeId}; use vectorcraft_geom::Rect; @@ -35,6 +35,13 @@ impl PdfSettings { pub fn bleed_of(&self, doc: &Document) -> [f64; 4] { self.bleed.of(doc) } + + /// A PDF/X file of `doc` has no bleed on a side where the document's bleed (Document Setup) has + /// one. + pub fn leaves_out_bleed(&self, doc: &Document) -> bool { + self.standard.is_pdfx() + && self.bleed_of(doc).into_iter().zip(bleed_in_range(doc.setup.bleed)).any(|(page, document)| page <= 0.0 && document > 0.0) + } } impl BleedSettings { diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 0c682b8d5..e4df914a1 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -82,6 +82,22 @@ fn bleed_grows_the_media_box_around_the_trim_box() { assert_eq!(page_box(&bytes, CropTo::Trim), Rect::new(3.0, 2.0, 203.0, 102.0)); } +#[test] +fn a_pdfx_file_leaves_out_the_document_bleed_side_by_side() { + let x4 = |bleed: serde_json::Value| settings(json!({"standard": "pdfX4", "bleed": bleed})); + let mut d = doc(); + d.setup.bleed = [6.0, 0.0, 0.0, 0.0]; + assert!(x4(json!({})).leaves_out_bleed(&d)); + assert!(x4(json!({"bottom": 1})).leaves_out_bleed(&d), "the top is left out"); + assert!(!x4(json!({"top": 1})).leaves_out_bleed(&d), "the sides without a document bleed don't count"); + assert!(!x4(json!({"useDocument": true})).leaves_out_bleed(&d)); + assert!(!settings(json!({})).leaves_out_bleed(&d), "not a PDF/X file"); + // A document bleed read from a file counts as it is kept in range. + d.setup.bleed = [f64::INFINITY, f64::NAN, -3.0, 0.0]; + assert!(!x4(json!({})).leaves_out_bleed(&d)); + assert!(!x4(json!({"useDocument": true})).leaves_out_bleed(&d)); +} + /// Each page's `UserUnit` and media box as the file holds them (in its units). fn units_and_media(bytes: &[u8]) -> Vec<(f32, Rect)> { let file = crate::pages::open(bytes, None).unwrap(); diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 1fb4bd77a..5e4117a18 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -12,8 +12,11 @@ workspace = true vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } +log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# The SHA-256 of a module, which the install prompt shows and a grant records. +sha2 = { version = "0.10", default-features = false } thiserror = { workspace = true } wasmi = { workspace = true } diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs new file mode 100644 index 000000000..f47d8e69b --- /dev/null +++ b/crates/plugins/src/host.rs @@ -0,0 +1,507 @@ +//! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on: +//! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit +//! asked it to stop, and `vc_log` writes a log line. A module may import them only when its +//! manifest gives `api` 2 and lists the `progress` permission, and the host checks each import when +//! the module is loaded ([`check_imports`], [`check_granted`]): one it doesn't offer, one with +//! another signature and one the manifest doesn't allow are refused by name. +//! +//! Each call's arguments are checked: progress is clamped and passed on only when it changes (with +//! a known total, to another tenth of a percent; past [`MAX_PROGRESS_REPORTS`], once per +//! [`PROGRESS_STRIDE`] changes), log text must lie inside the module's memory and be UTF-8, and +//! each line ([`MAX_LOG_LINE`]) and each run ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], +//! [`MAX_LOG_READ`]) are capped. Nothing a module passes can panic the host. + +use wasmi::{Caller, Engine, Extern, ExternType, Linker, Module, StoreLimits, ValType}; + +use crate::manifest::Manifest; +use crate::permissions::{self, PROGRESS_API, Permission}; +use crate::{Error, Result}; + +/// The module a plug-in imports host functions from (the name a Rust or C toolchain gives +/// `extern` functions by default). +pub const MODULE: &str = "env"; + +/// What the host passes as the step's description with a plug-in's progress +/// ([`vectorcraft_doc::cancel::progress`]), so a progress window shows a share rather than a count +/// of artboards or files. +pub const PROGRESS_STEP: &str = "plug-in"; + +/// Longest log line: its first 1,024 bytes of text, shown as at most 1,024 characters with hidden +/// characters escaped. A longer one is cut and ends with `…`. +pub const MAX_LOG_LINE: usize = 1024; + +/// Most log lines one run writes; later ones are dropped, after one "log truncated" line. +pub const MAX_LOG_LINES: usize = 200; + +/// Most bytes of log text, as written with its escapes, one run writes; lines past it are dropped +/// as past [`MAX_LOG_LINES`]. +pub const MAX_LOG_BYTES: usize = 32 * 1024; + +/// Most bytes of text the host reads from a run's `vc_log` calls, checked or not: a call that +/// would read past it is dropped as past [`MAX_LOG_LINES`]. +pub const MAX_LOG_READ: usize = 1024 * 1024; + +/// Most progress reports one run passes on at the full rate; after them, one changed report in +/// [`PROGRESS_STRIDE`] is, and so is the first after a total becomes known or unknown. The run's +/// last report is always kept ([`Report::progress`]). +pub const MAX_PROGRESS_REPORTS: usize = 2000; + +/// Past [`MAX_PROGRESS_REPORTS`], how many changed reports go by for each one passed on. +pub const PROGRESS_STRIDE: usize = 1000; + +/// What `vc_log` returns: the line was written. +pub const LOG_WRITTEN: i32 = 0; +/// What `vc_log` returns: the run's log budget is spent, so the line was dropped. +pub const LOG_DROPPED: i32 = 1; +/// What `vc_log` returns: the text doesn't lie inside the module's memory. +pub const LOG_OUTSIDE_MEMORY: i32 = -1; +/// What `vc_log` returns: the text is not UTF-8. +pub const LOG_NOT_UTF8: i32 = -2; +/// What `vc_log` returns: the level is not 0 to 3. +pub const LOG_BAD_LEVEL: i32 = -3; + +/// One host import: its name and its type. +pub struct Import { + pub name: &'static str, + params: &'static [ValType], + results: &'static [ValType], + /// Its type as WebAssembly text writes it. + pub signature: &'static str, +} + +/// The host imports of API level 2, in module [`MODULE`]: +/// +/// - `vc_progress(done: i64, total: i64)`: `done` of `total` steps are finished (`total` 0 or less: +/// not known). Negative values count as 0 and `done` is clamped to `total`; +/// - `vc_cancelled() -> i32`: 1 once the user cancelled the run or its time budget ran out, else 0; +/// - `vc_log(level: i32, ptr: i32, len: i32) -> i32`: writes the UTF-8 text at `ptr` as a log line +/// of `level` (0 error, 1 warning, 2 information, 3 debug) → [`LOG_WRITTEN`], [`LOG_DROPPED`], +/// [`LOG_OUTSIDE_MEMORY`], [`LOG_NOT_UTF8`] or [`LOG_BAD_LEVEL`]. +pub const IMPORTS: [Import; 3] = [ + Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], results: &[], signature: "(param i64 i64)" }, + Import { name: "vc_cancelled", params: &[], results: &[ValType::I32], signature: "(result i32)" }, + Import { + name: "vc_log", + params: &[ValType::I32, ValType::I32, ValType::I32], + results: &[ValType::I32], + signature: "(param i32 i32 i32) (result i32)", + }, +]; + +/// A log line's level. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, +} + +impl LogLevel { + /// The level `vc_log`'s `level` names. + fn from_code(code: i32) -> Option { + match code { + 0 => Some(LogLevel::Error), + 1 => Some(LogLevel::Warn), + 2 => Some(LogLevel::Info), + 3 => Some(LogLevel::Debug), + _ => None, + } + } + + fn log_level(self) -> log::Level { + match self { + LogLevel::Error => log::Level::Error, + LogLevel::Warn => log::Level::Warn, + LogLevel::Info => log::Level::Info, + LogLevel::Debug => log::Level::Debug, + } + } +} + +/// A line a plug-in wrote with `vc_log`: its control and bidirectional formatting characters +/// escaped ([`permissions::display_text`]), and cut to [`MAX_LOG_LINE`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogLine { + pub level: LogLevel, + pub text: String, +} + +/// The text of the line that stands for the lines a run's log budget dropped. +pub const LOG_TRUNCATED: &str = "log truncated: later lines were dropped"; + +/// What a run reported through the host imports. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Report { + /// Its last progress report, clamped: `(done, total)`, `total` 0 when not known. + pub progress: Option<(u64, u64)>, + /// The lines it wrote, then [`LOG_TRUNCATED`] once its budget dropped one. + pub lines: Vec, + /// Did its log budget drop a line? + pub truncated: bool, +} + +/// When a run must stop (native: a wall-clock deadline). +#[derive(Clone, Copy)] +pub(crate) struct Deadline { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant, +} + +impl Deadline { + pub(crate) fn new(wall_time_ms: u64) -> Self { + #[cfg(target_arch = "wasm32")] + let _ = wall_time_ms; + Self { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms), + } + } + + pub(crate) fn passed(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if std::time::Instant::now() >= self.at { + return true; + } + false + } + + pub(crate) fn check(&self) -> Result<()> { + if self.passed() { Err(Error::Limit("time budget".into())) } else { Ok(()) } + } +} + +/// One run's state that the host imports read and write. +pub(crate) struct Run { + /// The plug-in's id, which its log lines carry. + id: String, + /// Are its progress reports passed on ([`vectorcraft_doc::cancel::progress`])? A live + /// effect's are not: it is drawn as part of something else, such as an export. + forward: bool, + /// Are its log lines written to the app's log? Not while the module is being loaded. + logged: bool, + deadline: Deadline, + /// The last report passed on, as per mille of a known total (with 1) or the count of an + /// unknown one (with 0), how many were, and how many changed ones went by since the last. + last: Option<(u64, u64)>, + reports: usize, + skipped: usize, + /// Bytes of log text written (with their escapes) and read. + log_bytes: usize, + log_read: usize, + pub(crate) report: Report, +} + +impl Run { + pub(crate) fn new(id: &str, forward: bool, logged: bool, deadline: Deadline) -> Self { + Run { + id: id.to_string(), + forward, + logged, + deadline, + last: None, + reports: 0, + skipped: 0, + log_bytes: 0, + log_read: 0, + report: Report::default(), + } + } + + fn progress(&mut self, done: i64, total: i64) { + let total = u64::try_from(total).unwrap_or(0); + let done = u64::try_from(done).unwrap_or(0); + let done = if total > 0 { done.min(total) } else { done }; + self.report.progress = Some((done, total)); + let key = if total > 0 { ((u128::from(done) * 1000 / u128::from(total)) as u64, 1) } else { (done, 0) }; + if !self.forward || self.last == Some(key) { + return; + } + let switched = self.last.is_some_and(|(_, t)| (t > 0) != (total > 0)); + if self.reports >= MAX_PROGRESS_REPORTS && !switched { + self.skipped += 1; + if self.skipped < PROGRESS_STRIDE { + return; + } + } + self.skipped = 0; + self.last = Some(key); + self.reports += 1; + vectorcraft_doc::cancel::progress(done, total, PROGRESS_STEP); + } + + fn cancelled(&self) -> i32 { + i32::from(vectorcraft_doc::cancel::is_cancelled() || self.deadline.passed()) + } + + /// Writes the `len` bytes at `ptr` in `memory` as a line of `level` → what `vc_log` returns. + fn log(&mut self, level: i32, memory: &[u8], ptr: usize, len: usize) -> i32 { + let Some(level) = LogLevel::from_code(level) else { return LOG_BAD_LEVEL }; + let Some(bytes) = ptr.checked_add(len).and_then(|end| memory.get(ptr..end)) else { return LOG_OUTSIDE_MEMORY }; + if self.report.truncated { + return LOG_DROPPED; + } + if self.log_read.saturating_add(len) > MAX_LOG_READ { + return self.truncate(); + } + self.log_read += len; + let Ok(text) = std::str::from_utf8(bytes) else { return LOG_NOT_UTF8 }; + // A character the cut splits is left out. + let head = text.get(..text.floor_char_boundary(MAX_LOG_LINE)).unwrap_or(text); + let shown = if head.len() < text.len() { + let mut shown = permissions::display_text(head, MAX_LOG_LINE - 1); + if !shown.ends_with('…') { + shown.push('…'); + } + shown + } else { + permissions::display_text(head, MAX_LOG_LINE) + }; + if self.report.lines.len() >= MAX_LOG_LINES || self.log_bytes + shown.len() > MAX_LOG_BYTES { + return self.truncate(); + } + self.log_bytes += shown.len(); + self.write(level, shown); + LOG_WRITTEN + } + + /// Drops the line and the run's later ones, after one line that says so → [`LOG_DROPPED`]. + fn truncate(&mut self) -> i32 { + self.report.truncated = true; + self.write(LogLevel::Warn, LOG_TRUNCATED.to_string()); + LOG_DROPPED + } + + fn write(&mut self, level: LogLevel, text: String) { + if self.logged { + log::log!(target: "vectorcraft_plugins", level.log_level(), "{}: {text}", self.id); + } + self.report.lines.push(LogLine { level, text }); + } +} + +/// What a store holds: its memory limits and the run's state. +pub(crate) struct Host { + pub(crate) limits: StoreLimits, + pub(crate) run: Run, +} + +/// A linker that offers the host imports when `granted`, and no import otherwise. +pub(crate) fn linker(engine: &Engine, granted: bool) -> Linker { + let mut linker = Linker::::new(engine); + if granted { + // Each name is defined once, so none of these fails. + let _ = linker.func_wrap(MODULE, "vc_progress", |mut caller: Caller<'_, Host>, done: i64, total: i64| { + caller.data_mut().run.progress(done, total); + }); + let _ = linker.func_wrap(MODULE, "vc_cancelled", |caller: Caller<'_, Host>| -> i32 { caller.data().run.cancelled() }); + let _ = linker.func_wrap(MODULE, "vc_log", |mut caller: Caller<'_, Host>, level: i32, ptr: i32, len: i32| -> i32 { + let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return LOG_OUTSIDE_MEMORY }; + let (data, host) = memory.data_and_store_mut(&mut caller); + host.run.log(level, data, ptr as u32 as usize, len as u32 as usize) + }); + } + linker +} + +/// Checks that every import of `module` is a host import with the host's signature → their names. +/// Any other import is refused, and the error names it. +pub(crate) fn check_imports(module: &Module) -> Result> { + let mut names = Vec::new(); + for imp in module.imports() { + let offered = if imp.module() == MODULE { IMPORTS.iter().find(|i| i.name == imp.name()) } else { None }; + let (from, name) = (permissions::display_text(imp.module(), 64), permissions::display_text(imp.name(), 64)); + let Some(host) = offered else { + return Err(Error::Abi(format!( + "the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network" + ))); + }; + match imp.ty() { + ExternType::Func(f) if f.params() == host.params && f.results() == host.results => names.push(host.name), + _ => { + let signature = host.signature; + return Err(Error::Abi(format!("the module imports `env.{name}` with another type than the host's: it is {signature}"))); + } + } + } + Ok(names) +} + +/// Checks that `manifest` allows the host imports `imports`: `api` 2 and the `progress` +/// permission. The error names the first import it doesn't allow. +pub(crate) fn check_granted(manifest: &Manifest, imports: &[&str]) -> Result<()> { + match imports.first() { + Some(name) if manifest.api < PROGRESS_API || !manifest.permissions().contains(&Permission::Progress) => Err(Error::Abi(format!( + "the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress`" + ))), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run() -> Run { + Run::new("test", true, false, Deadline::new(60_000)) + } + + /// Progress is clamped: negative counts are 0 and `done` never passes a known total. + #[test] + fn progress_is_clamped() { + let mut r = run(); + for ((done, total), want) in + [((5, 3), (3, 3)), ((-4, 10), (0, 10)), ((7, -1), (7, 0)), ((i64::MAX, i64::MAX), (i64::MAX as u64, i64::MAX as u64))] + { + r.progress(done, total); + assert_eq!(r.report.progress, Some(want), "{done} of {total}"); + } + } + + /// Reports are passed on only when they change (with a known total, to another tenth of a + /// percent, however the total changes; with an unknown total, each change of `done`), and past + /// [`MAX_PROGRESS_REPORTS`] once per [`PROGRESS_STRIDE`] changes, except the first after the + /// total becomes known; the last one is always kept. + #[test] + fn progress_is_passed_on_at_a_bounded_rate() { + use std::cell::RefCell; + use std::rc::Rc; + let seen = Rc::new(RefCell::new(Vec::new())); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let token = vectorcraft_doc::cancel::CancelToken::new(); + let mut r = run(); + vectorcraft_doc::cancel::scope(&token, Some(sink), || { + for done in 0..=1_000_000 { + r.progress(done, 1_000_000); + } + }); + let seen = seen.borrow(); + assert_eq!(seen.len(), 1001, "one per tenth of a percent"); + assert_eq!(seen.last(), Some(&(1_000_000, 1_000_000, PROGRESS_STEP.to_string()))); + let mut unknown = run(); + let later = Rc::new(RefCell::new(Vec::new())); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let later = later.clone(); + Rc::new(move |d, t, _| later.borrow_mut().push((d, t))) + }; + vectorcraft_doc::cancel::scope(&token, Some(sink), || { + for done in 0..10_000 { + unknown.progress(done, 0); + } + assert_eq!(unknown.reports, MAX_PROGRESS_REPORTS + (10_000 - MAX_PROGRESS_REPORTS) / PROGRESS_STRIDE); + assert_eq!(unknown.report.progress, Some((9_999, 0)), "the last report is kept"); + // A total that becomes known is passed on at once, and the reports after it keep + // coming at the slower rate. + unknown.progress(10, 100); + assert_eq!(later.borrow().last(), Some(&(10, 100))); + for done in 11..=100 { + unknown.progress(done, 100); + } + }); + assert_eq!(later.borrow().last(), Some(&(10, 100)), "90 changes are fewer than the stride"); + assert_eq!(unknown.report.progress, Some((100, 100))); + // A new total at the same tenth of a percent is not a new step. + let mut retotalled = run(); + let steps = Rc::new(RefCell::new(Vec::new())); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let steps = steps.clone(); + Rc::new(move |d, t, _| steps.borrow_mut().push((d, t))) + }; + vectorcraft_doc::cancel::scope(&token, Some(sink), || { + for total in 10..2_010 { + retotalled.progress(total / 10, total - total % 10); + } + }); + assert_eq!(*steps.borrow(), [(1, 10)]); + assert_eq!(retotalled.report.progress, Some((200, 2_000))); + } + + /// Text outside memory, text that isn't UTF-8 and an unknown level are errors; a long line is + /// cut, also inside a character, and hidden characters are escaped. + #[test] + fn log_text_is_checked_and_cut() { + let mut r = run(); + let memory = b"hello\xff\xfe\nworld"; + assert_eq!(r.log(2, memory, 0, 5), LOG_WRITTEN); + assert_eq!(r.log(2, memory, 0, memory.len() + 1), LOG_OUTSIDE_MEMORY); + assert_eq!(r.log(2, memory, usize::MAX, 2), LOG_OUTSIDE_MEMORY); + assert_eq!(r.log(2, memory, 4, 3), LOG_NOT_UTF8); + assert_eq!(r.log(4, memory, 0, 5), LOG_BAD_LEVEL); + assert_eq!(r.log(-1, memory, 0, 5), LOG_BAD_LEVEL); + assert_eq!(r.log(0, memory, 7, 6), LOG_WRITTEN); + // The cut falls inside the 512th "é", which is left out. + let long = format!("a{}", "é".repeat(MAX_LOG_LINE)); + assert_eq!(r.log(1, long.as_bytes(), 0, long.len()), LOG_WRITTEN); + let lines = &r.report.lines; + assert_eq!(lines[0], LogLine { level: LogLevel::Info, text: "hello".into() }); + assert_eq!(lines[1], LogLine { level: LogLevel::Error, text: "\\u{000A}world".into() }); + assert_eq!(lines[2], LogLine { level: LogLevel::Warn, text: format!("a{}…", "é".repeat(MAX_LOG_LINE / 2 - 1)) }); + assert!(!r.report.truncated); + // Text past the cut is checked too. + let bad_tail = [vec![b'a'; MAX_LOG_LINE * 2], vec![0xff]].concat(); + assert_eq!(r.log(2, &bad_tail, 0, bad_tail.len()), LOG_NOT_UTF8); + // A cut line of escapes is at most MAX_LOG_LINE characters, its ellipsis included. + let controls = vec![1u8; MAX_LOG_LINE * 2]; + assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN); + let text = &r.report.lines[3].text; + assert!(text.starts_with("\\u{0001}") && text.ends_with('…') && text.chars().count() <= MAX_LOG_LINE, "{text}"); + } + + /// The byte budget counts a line as written, with its escapes, so short lines of hidden + /// characters can't write more than [`MAX_LOG_BYTES`]. + #[test] + fn log_budget_counts_escaped_text() { + let mut r = run(); + let controls = [1u8; 100]; + let shown = "\\u{0001}".len() * controls.len(); + let fit = MAX_LOG_BYTES / shown; + for i in 0..fit { + assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN, "line {i}"); + } + assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_DROPPED); + let written: usize = r.report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum(); + assert!(written <= MAX_LOG_BYTES, "{written}"); + } + + /// The host reads at most [`MAX_LOG_READ`] bytes of log text in a run, so long texts can't + /// make it check more; once past it, later calls are dropped without reading. + #[test] + fn log_text_read_is_bounded() { + let mut r = run(); + let big = vec![b'a'; MAX_LOG_READ / 4]; + for _ in 0..4 { + assert_eq!(r.log(2, &big, 0, big.len()), LOG_WRITTEN); + } + assert_eq!(r.log(2, b"x", 0, 1), LOG_DROPPED); + assert!(r.report.truncated); + let mut huge = run(); + let over = vec![0xffu8; MAX_LOG_READ + 1]; + assert_eq!(huge.log(2, &over, 0, over.len()), LOG_DROPPED, "dropped before it is checked"); + assert_eq!(huge.log(2, b"x", 0, 1), LOG_DROPPED); + } + + /// Lines past the run's budget are dropped, with one line that says so. + #[test] + fn log_lines_past_the_budget_are_dropped_with_one_truncation_line() { + let mut r = run(); + for i in 0..MAX_LOG_LINES + 50 { + let want = if i < MAX_LOG_LINES { LOG_WRITTEN } else { LOG_DROPPED }; + assert_eq!(r.log(2, b"line", 0, 4), want, "line {i}"); + } + assert_eq!(r.report.lines.len(), MAX_LOG_LINES + 1); + assert_eq!(r.report.lines.last(), Some(&LogLine { level: LogLevel::Warn, text: LOG_TRUNCATED.into() })); + assert!(r.report.truncated); + // So is text past the run's bytes. + let mut bytes = run(); + let big = vec![b'x'; MAX_LOG_LINE]; + let fit = MAX_LOG_BYTES / MAX_LOG_LINE; + for _ in 0..fit { + assert_eq!(bytes.log(2, &big, 0, big.len()), LOG_WRITTEN); + } + assert_eq!(bytes.log(2, b"x", 0, 1), LOG_DROPPED); + assert_eq!(bytes.report.lines.len(), fit + 1); + } +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 061ef506c..5e5da17b9 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -3,8 +3,9 @@ //! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented //! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with: //! -//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that -//! imports anything is rejected; +//! - **no host imports but the three of API level 2** ([`host`]: progress, Cancel and log lines, +//! with the `progress` permission): no WASI, file system, network, clock or randomness. A module +//! that imports anything else is rejected; //! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is //! also enforced (native builds), so infinite loops end with an error. A filter may ask for a //! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it; @@ -21,19 +22,23 @@ //! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole //! document in a file format of its own. The process-wide [`registry`] holds the installed //! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show -//! them well: labels, units, groups, help and conditions ([`dialog`]). +//! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is +//! the set of [`permissions`] its manifest lists, which the user approves when it is installed. #![forbid(unsafe_code)] pub mod dialog; pub mod effect; pub mod export; +pub mod host; pub mod manifest; pub mod objects; +pub mod permissions; pub mod registry; mod runtime; pub mod wat; pub use dialog::ParamUi; +pub use host::{LogLevel, LogLine, Report}; pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 18ffeb335..36b4b78c4 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::dialog::ParamUi; +use crate::permissions::{self, API_LEVEL, PROGRESS_API, Permission}; use crate::{Error, Result}; /// Longest manifest the host reads. @@ -154,6 +155,12 @@ fn text_max() -> usize { fn list_max() -> usize { 16 } +fn first_api() -> u32 { + 1 +} +fn is_first_api(api: &u32) -> bool { + *api == 1 +} /// One declared parameter: what it holds, and how dialogs show it. In the manifest both are keys of /// the same object: `{"type": "number", "min": 0, "max": 10, "default": 1, "unit": "mm"}`. @@ -236,6 +243,13 @@ pub struct Manifest { pub description: String, #[serde(default)] pub author: String, + /// The plug-in API level it was built for, 1 to [`API_LEVEL`] (1 when left out). + #[serde(default = "first_api", skip_serializing_if = "is_first_api")] + pub api: u32, + /// The permissions it asks for ([`crate::permissions`]), which the user approves when it is + /// installed. Left out, it gets the ones its kind implies ([`Permission::implied`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option>, /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] pub params: Vec<(String, Param)>, @@ -269,6 +283,8 @@ impl Manifest { kind: Kind::Filter, description: String::new(), author: String::new(), + api: 1, + permissions: None, params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, @@ -288,6 +304,15 @@ impl Manifest { if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 { return Err(Error::Manifest("`version`, `description` or `author` is too long".into())); } + if !(1..=API_LEVEL).contains(&self.api) { + return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api))); + } + if let Some(names) = &self.permissions + && permissions::validate(self.kind, names)?.contains(&Permission::Progress) + && self.api < PROGRESS_API + { + return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); + } self.validate_extensions()?; match (self.budget, self.kind) { (Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {} @@ -363,6 +388,19 @@ impl Manifest { self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect() } + /// What the plug-in may do: the permissions it lists, else the ones its kind implies. + pub fn permissions(&self) -> Vec { + match &self.permissions { + Some(names) => permissions::validate(self.kind, names).unwrap_or_default(), + None => Permission::implied(self.kind).to_vec(), + } + } + + /// Does installing it need the user's approval: does it list permissions? + pub fn asks_permissions(&self) -> bool { + self.permissions.as_ref().is_some_and(|p| !p.is_empty()) + } + /// Validates user `params` against the schema: missing keys take their defaults, numbers are /// clamped into range (whole numbers rounded), unknown keys are dropped; a value of the wrong /// type is an error. @@ -595,6 +633,47 @@ mod tests { } } + /// A manifest lists the permissions it asks for, checked against its kind; without the key it + /// gets the ones its kind implies, as plug-ins did before manifests listed them, and needs no + /// approval. `api` is 1 when left out, and a later level is refused. + #[test] + fn permissions_and_api_level() { + let listed = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["doc.write","doc.read"]}"#).unwrap(); + assert_eq!(listed.permissions(), [Permission::DocRead, Permission::DocWrite]); + assert!(listed.asks_permissions()); + assert_eq!(serde_json::to_value(&listed).unwrap()["permissions"], json!(["doc.write", "doc.read"])); + assert!(serde_json::to_value(&listed).unwrap().get("api").is_none(), "level 1 isn't written back"); + let plain = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap(); + assert_eq!((plain.api, plain.asks_permissions()), (1, false)); + assert_eq!(plain.permissions(), [Permission::DocRead, Permission::DocWrite, Permission::State]); + assert!(serde_json::to_value(&plain).unwrap().get("permissions").is_none()); + let effect = Manifest::parse(br#"{"id":"a","name":"x","kind":"effect"}"#).unwrap(); + assert_eq!(effect.permissions(), [Permission::DocRead, Permission::EffectRender]); + let exporter = Manifest::parse(br#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"]}"#).unwrap(); + assert_eq!(exporter.permissions(), [Permission::DocRead, Permission::ExportWrite]); + assert!(!Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","permissions":[]}"#).unwrap().asks_permissions()); + // Level 2 adds `progress`, which only a level-2 manifest may list. + let level2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["progress","doc.read"]}"#).unwrap(); + assert_eq!((level2.api, level2.permissions()), (2, vec![Permission::DocRead, Permission::Progress])); + assert_eq!(serde_json::to_value(&level2).unwrap()["api"], json!(2)); + let plain2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2}"#).unwrap(); + assert!(!plain2.permissions().contains(&Permission::Progress), "never implied"); + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"filter","permissions":["doc.read","net.fetch"]}"#, "unknown permission `net.fetch`"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"), + (r#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read"]}"#, "effect.render"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"), + (r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"), + (r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs new file mode 100644 index 000000000..083c78a04 --- /dev/null +++ b/crates/plugins/src/permissions.rs @@ -0,0 +1,290 @@ +//! What a plug-in may do: the permissions its manifest lists (`permissions`), which the user +//! approves when it is installed ([`Grant`], kept in [`crate::registry`]), and which the host +//! enforces on every run. +//! +//! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in +//! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without +//! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of +//! that kind could do before manifests listed them. `progress`, which lets a plug-in use the host +//! imports of API level 2 ([`crate::host`]), is never implied: a manifest lists it. + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Kind, Result}; + +/// The plug-in API level this host implements. A manifest's `api` (1 when left out) may be at most +/// this. +pub const API_LEVEL: u32 = 2; + +/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress` +/// permission they need. +pub const PROGRESS_API: u32 = 2; + +/// Most permissions a manifest may list. +pub const MAX_PERMISSIONS: usize = 16; + +/// Longest plug-in name a prompt shows, in characters. +pub const MAX_SHOWN_NAME: usize = 64; + +/// Longest version or id a prompt shows, in characters. +pub const MAX_SHOWN_FIELD: usize = 64; + +/// Names kept for host calls a later plug-in API adds. This host grants none of them, so a +/// manifest that lists one is refused when it is installed. +pub const RESERVED: &[&str] = &["ui.panel", "ui.tool"]; + +/// One thing a plug-in may do. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Permission { + /// Read the objects the host hands it: a filter's selected paths, the object a live effect is + /// on, the document an exporter writes. + #[serde(rename = "doc.read")] + DocRead, + /// Change, add and delete objects (a filter's output), as one undo step. + #[serde(rename = "doc.write")] + DocWrite, + /// Read and write the data it keeps with the document (a filter's `documentData`). + #[serde(rename = "state")] + State, + /// Change how the objects its live effect is applied to are drawn. + #[serde(rename = "effect.render")] + EffectRender, + /// Write the document as a file in its own format (an exporter). + #[serde(rename = "export.write")] + ExportWrite, + /// Report how far a run has got, see that the user asked it to stop and write log lines: the + /// host imports of API level 2 ([`crate::host::IMPORTS`]). + #[serde(rename = "progress")] + Progress, +} + +/// Every permission this host grants, in the order prompts list them. +pub const ALL: [Permission; 6] = + [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::Progress]; + +impl Permission { + /// Its name in a manifest and in `plugin.install`'s `grant`. + pub fn name(self) -> &'static str { + match self { + Permission::DocRead => "doc.read", + Permission::DocWrite => "doc.write", + Permission::State => "state", + Permission::EffectRender => "effect.render", + Permission::ExportWrite => "export.write", + Permission::Progress => "progress", + } + } + + /// The permission named `name`. + pub fn parse(name: &str) -> Option { + ALL.into_iter().find(|p| p.name() == name) + } + + /// What it lets a plug-in do, in plain words (English; the app shows its own translation). + pub fn describe(self) -> &'static str { + match self { + Permission::DocRead => "Read the objects it is given", + Permission::DocWrite => "Change, add and delete objects in the document", + Permission::State => "Keep data of its own with the document", + Permission::EffectRender => "Change how the objects its effect is applied to are drawn", + Permission::ExportWrite => "Write the document as a file in its own format", + Permission::Progress => "Show its progress, see Cancel and write log lines", + } + } + + /// May a plug-in of `kind` list it: one its kind implies, or `progress`, which any kind may + /// list. + pub fn applies(self, kind: Kind) -> bool { + self == Permission::Progress || Permission::implied(kind).contains(&self) + } + + /// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list + /// `progress` ([`Permission::applies`]). + pub fn implied(kind: Kind) -> &'static [Permission] { + match kind { + Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], + Kind::Effect => &[Permission::DocRead, Permission::EffectRender], + Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite], + } + } + + /// The permissions a plug-in of `kind` can't work without, which its list must hold: a live + /// effect and an exporter are always handed objects, and only draw or write. A filter may + /// leave any of its permissions out. + fn required(kind: Kind) -> &'static [Permission] { + match kind { + Kind::Filter => &[], + Kind::Effect | Kind::Exporter => Permission::implied(kind), + } + } +} + +/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has. A name +/// that is not a permission, a reserved one, one that doesn't apply to the kind, one listed twice +/// and a list without what the kind needs are errors that name it. +pub(crate) fn validate(kind: Kind, names: &[String]) -> Result> { + if names.len() > MAX_PERMISSIONS { + return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`"))); + } + let mut out: Vec = Vec::with_capacity(names.len()); + for name in names { + let shown = display_text(name, MAX_SHOWN_FIELD); + let Some(p) = Permission::parse(name) else { + if RESERVED.contains(&name.as_str()) { + return Err(Error::Manifest(format!("permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it"))); + } + return Err(Error::Manifest(format!("unknown permission `{shown}`"))); + }; + if !p.applies(kind) { + return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name()))); + } + if out.contains(&p) { + return Err(Error::Manifest(format!("permission `{shown}` is listed twice"))); + } + out.push(p); + } + if let Some(missing) = Permission::required(kind).iter().find(|p| !out.contains(p)) { + return Err(Error::Manifest(format!("a plug-in of kind `{}` needs the permission `{}`", kind.name(), missing.name()))); + } + out.sort(); + Ok(out) +} + +/// The names of `permissions`, as a manifest and `grant` list them. +pub fn names(permissions: &[Permission]) -> Vec<&'static str> { + permissions.iter().map(|p| p.name()).collect() +} + +/// Is `c` a character that changes the direction text is laid out in, or a line or paragraph +/// separator, which a prompt must not let a manifest string use to disguise itself? +fn is_bidi_or_separator(c: char) -> bool { + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{2028}' | '\u{2029}') +} + +/// `s` as a prompt shows a string from a manifest: control, bidirectional formatting and line +/// separator characters written as `\u{202E}` escapes, and at most `max` characters, the last of +/// them `…` when it was cut. +pub fn display_text(s: &str, max: usize) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + // Characters written so far, and the byte length of the longest prefix of whole pieces + // shorter than `max` characters, where the ellipsis goes when the text is cut. + let mut count = 0; + let mut cut = 0; + for c in s.chars() { + let start = out.len(); + let n = if c.is_control() || is_bidi_or_separator(c) { + let _ = write!(out, "\\u{{{:04X}}}", c as u32); + out.len() - start + } else { + out.push(c); + 1 + }; + if count + n > max { + out.truncate(cut); + out.push('…'); + return out; + } + count += n; + if count < max { + cut = out.len(); + } + } + out +} + +/// The SHA-256 of `bytes`, in lower-case hexadecimal. +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} + +/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved +/// ([`sha256_hex`]) and the permissions it was granted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Grant { + pub id: String, + pub hash: String, + pub permissions: Vec, +} + +impl Grant { + /// Does it grant every one of `wanted`? + pub fn covers(&self, wanted: &[Permission]) -> bool { + wanted.iter().all(|w| self.permissions.contains(w)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_round_trip_and_serialize_as_names() { + for p in ALL { + assert_eq!(Permission::parse(p.name()), Some(p)); + assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.name())); + } + assert_eq!(Permission::parse("document.read"), None); + assert!(RESERVED.iter().all(|r| Permission::parse(r).is_none()), "reserved names are not granted"); + } + + #[test] + fn lists_are_checked_against_the_kind() { + let list = |names: &[&str]| names.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(validate(Kind::Filter, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]); + assert_eq!(validate(Kind::Filter, &[]).unwrap(), []); + assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok()); + assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok()); + // Any kind may list `progress`, which no kind implies. + assert_eq!(validate(Kind::Filter, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]); + assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok()); + assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok()); + assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress))); + for (kind, names, says) in [ + (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), + (Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"), + (Kind::Filter, &["progress", "progress"], "`progress` is listed twice"), + (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), + (Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), + (Kind::Effect, &["doc.read"], "needs the permission `effect.render`"), + (Kind::Exporter, &["export.write"], "needs the permission `doc.read`"), + ] { + let e = validate(kind, &list(names)).unwrap_err().to_string(); + assert!(e.contains(says), "{names:?}: {e}"); + } + let many = list(&["doc.read"; MAX_PERMISSIONS + 1]); + assert!(validate(Kind::Filter, &many).unwrap_err().to_string().contains("at most")); + } + + /// Control, bidirectional override and separator characters show as escapes, so a name can't + /// reverse or hide the text after it; long text is cut with an ellipsis. + #[test] + fn prompt_text_escapes_hidden_characters_and_is_capped() { + assert_eq!(display_text("Tidy\u{202E}fdp.exe", 64), "Tidy\\u{202E}fdp.exe"); + assert_eq!(display_text("a\u{0007}b\nc\u{2066}d\u{200F}", 64), "a\\u{0007}b\\u{000A}c\\u{2066}d\\u{200F}"); + assert_eq!(display_text("plain", 64), "plain"); + assert_eq!(display_text("abcdef", 6), "abcdef"); + assert_eq!(display_text("abcdefg", 6), "abcde…"); + assert_eq!(display_text(&"x".repeat(500), MAX_SHOWN_NAME).chars().count(), MAX_SHOWN_NAME); + assert_eq!(display_text("ab\u{202E}", 6), "ab…", "an escape that doesn't fit is cut whole"); + assert_eq!(display_text("ab\u{0007}", 10), "ab\\u{0007}", "an escape that ends exactly at the cap is kept"); + let long = display_text(&"\u{202E}y".repeat(500_000), MAX_SHOWN_NAME); + assert_eq!(long.chars().count(), MAX_SHOWN_NAME); + assert!(long.starts_with("\\u{202E}y\\u{202E}y") && long.ends_with('…'), "{long}"); + } + + #[test] + fn hashes_are_sha256_in_hex() { + assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + } + + #[test] + fn a_grant_covers_its_permissions_and_no_more() { + let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] }; + assert!(g.covers(&[Permission::DocRead])); + assert!(g.covers(&[])); + assert!(!g.covers(&[Permission::State])); + assert!(!g.covers(&[Permission::DocRead, Permission::State])); + } +} diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index caf63eb54..0302ab6be 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -9,10 +9,41 @@ use std::sync::{Arc, Mutex, RwLock}; #[cfg(not(target_arch = "wasm32"))] use crate::Error; +use crate::permissions::Grant; use crate::{Limits, Plugin, Result}; static PLUGINS: RwLock>> = RwLock::new(BTreeMap::new()); static REVISION: AtomicU64 = AtomicU64::new(0); +/// What the user approved, by plug-in id. +static GRANTS: RwLock> = RwLock::new(BTreeMap::new()); + +/// Records what the user approved for a plug-in, replacing an earlier grant for its id. +pub fn grant(g: Grant) { + GRANTS.write().unwrap_or_else(|e| e.into_inner()).insert(g.id.clone(), g); +} + +/// What the user approved for plug-in `id`. +pub fn granted(id: &str) -> Option { + GRANTS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} + +/// Every grant, sorted by id. +pub fn grants() -> Vec { + GRANTS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect() +} + +/// Forgets what the user approved for `id`; false if nothing was. +pub fn revoke(id: &str) -> bool { + GRANTS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some() +} + +/// May `plugin` be installed without asking the user: does it list no permissions, or did the user +/// already grant its id every permission it lists? A module with other bytes but no new permission +/// needs no new approval. +pub fn approved(plugin: &Plugin) -> bool { + let m = plugin.manifest(); + !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions())) +} /// Installs `plugin`, replacing one with the same id. pub fn install(mut plugin: Plugin) -> Arc { @@ -93,16 +124,21 @@ pub struct FolderReport { /// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is /// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]), -/// which installs only once the user agrees. +/// which installs only once the user agrees, and one that lists permissions the user has not +/// granted its id ([`approved`]). #[cfg(not(target_arch = "wasm32"))] pub fn load_folder(dir: &std::path::Path) -> Result { - load_folder_where(dir, |_| Ok(())) + load_folder_where(dir, |_| Ok(()), approved) } -/// [`load_folder`], loading only the files `allow` accepts; the others are reported as failed -/// with its reason. +/// [`load_folder`], loading only the files `allow` accepts, and of the plug-ins that list +/// permissions only those `approve` accepts; the others are reported as failed with the reason. #[cfg(not(target_arch = "wasm32"))] -pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>) -> Result { +pub fn load_folder_where( + dir: &std::path::Path, + allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>, + approve: impl Fn(&Plugin) -> bool, +) -> Result { let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?; let mut files: Vec = rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect(); @@ -119,6 +155,13 @@ pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) Ok(p) if p.manifest().budget == crate::Budget::Heavy => { report.failed.push((f.display().to_string(), "asks for a heavy budget, which the user allows when installing it on its own".into())); } + Ok(p) if p.manifest().asks_permissions() && !approve(&p) => { + let asked = crate::permissions::names(&p.manifest().permissions()).join(", "); + report.failed.push(( + f.display().to_string(), + format!("needs approval: it asks for {asked}, which the user grants when installing it on its own"), + )); + } Ok(p) => report.loaded.push(install(p).id().to_string()), Err(e) => report.failed.push((f.display().to_string(), e.to_string())), } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 3a72b70d7..e6c289b8f 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -1,12 +1,14 @@ //! The wasmi host: loading, instantiating and calling a plug-in under resource limits. use wasmi::{ - Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, - TypedResumableCall, WasmParams, WasmResults, + Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Memory, Module, Store, StoreLimitsBuilder, TypedFunc, TypedResumableCall, WasmParams, + WasmResults, }; +use crate::host::{self, Deadline, Host, Report, Run}; use crate::manifest::{MAX_MANIFEST_BYTES, Manifest}; -use crate::{Error, Result}; +use crate::permissions::Permission; +use crate::{Error, Kind, Result}; /// The plug-in ABI version this host implements (`vc_abi_version` must return it). pub const ABI_VERSION: i32 = 1; @@ -85,6 +87,7 @@ pub struct Plugin { module: Module, limits: Limits, size: usize, + hash: String, source: Option, serial: u64, } @@ -95,33 +98,10 @@ impl std::fmt::Debug for Plugin { } } -/// When a run must stop (native: a wall-clock deadline). -struct Deadline { - #[cfg(not(target_arch = "wasm32"))] - at: std::time::Instant, -} - -impl Deadline { - fn new(limits: &Limits) -> Self { - #[cfg(target_arch = "wasm32")] - let _ = limits; - Self { - #[cfg(not(target_arch = "wasm32"))] - at: std::time::Instant::now() + std::time::Duration::from_millis(limits.wall_time_ms), - } - } - fn check(&self) -> Result<()> { - #[cfg(not(target_arch = "wasm32"))] - if std::time::Instant::now() >= self.at { - return Err(Error::Limit("time budget".into())); - } - Ok(()) - } -} - -/// One live instance: a store with its own memory limit, and the module's exports. +/// One live instance: a store with its own memory limit and the run's state, and the module's +/// exports. struct Live { - store: Store, + store: Store, instance: Instance, memory: Memory, } @@ -151,7 +131,9 @@ fn wasm_err(e: wasmi::Error) -> Error { type RunArgs = (i32, i32, i32, i32); impl Plugin { - /// Validates and loads a module: checks the ABI version, the exports and reads the manifest. + /// Validates and loads a module: checks the ABI version, the exports and reads the manifest, and + /// checks that each host import it asks for is one the host offers ([`crate::host::IMPORTS`]) + /// and its manifest allows. pub fn load(bytes: &[u8], limits: Limits) -> Result { if bytes.len() > limits.max_module_bytes { return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes))); @@ -167,16 +149,12 @@ impl Plugin { .set_max_recursion_depth(limits.max_recursion_depth.max(16)); let engine = Engine::new(&config); let module = Module::new(&engine, bytes).map_err(|e| Error::Module(e.to_string()))?; - if let Some(imp) = module.imports().next() { - return Err(Error::Abi(format!( - "the module imports `{}.{}`, but plug-ins get no host imports (no WASI, files or network)", - imp.module(), - imp.name() - ))); - } - let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), source: None, serial: 0 }; - let deadline = Deadline::new(&p.limits); - let mut live = p.instantiate()?; + let imports = host::check_imports(&module)?; + let hash = crate::permissions::sha256_hex(bytes); + let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), hash, source: None, serial: 0 }; + let deadline = Deadline::new(p.limits.wall_time_ms); + // The manifest isn't read yet: every host import is linked, and what it reports is dropped. + let mut live = p.instantiate(Run::new("", false, false, deadline), true, &mut Report::default())?; let version: TypedFunc<(), i32> = live.func("vc_abi_version")?; let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?; if v != ABI_VERSION { @@ -189,6 +167,7 @@ impl Plugin { e => e, })?; p.manifest = Manifest::parse(&text)?; + host::check_granted(&p.manifest, &imports)?; // A heavy filter's runs get the larger budget the host allows for one, which the host // lets the user allow or refuse before installing it. if p.manifest.budget == crate::Budget::Heavy { @@ -214,6 +193,10 @@ impl Plugin { pub fn size(&self) -> usize { self.size } + /// The module's SHA-256, in lower-case hexadecimal. + pub fn hash(&self) -> &str { + &self.hash + } /// Where the module was loaded from, if it came from a file. pub fn source(&self) -> Option<&str> { self.source.as_deref() @@ -231,16 +214,24 @@ impl Plugin { self.serial = serial; } - fn instantiate(&self) -> Result { + /// A new instance for `run`, with the host imports linked when `granted`. When the module's + /// start function fails, what it reported through the host imports goes to `report`. + fn instantiate(&self, run: Run, granted: bool, report: &mut Report) -> Result { let limits = StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); - let mut store = Store::new(&self.engine, limits); - store.limiter(|l| l); + let mut store = Store::new(&self.engine, Host { limits, run }); + store.limiter(|h| &mut h.limits); // The start function (if any) runs on one slice of fuel: it runs before `call` can check // the deadline or a cancel between slices, so a heavy budget would let it run for minutes. store.set_fuel(self.limits.fuel_base.min(FUEL_SLICE)).map_err(wasm_err)?; - let linker = Linker::::new(&self.engine); - let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?; + let linker = host::linker(&self.engine, granted); + let instance = match linker.instantiate_and_start(&mut store, &self.module) { + Ok(instance) => instance, + Err(e) => { + *report = std::mem::take(&mut store.data_mut().run.report); + return Err(wasm_err(e)); + } + }; let memory = instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?; Ok(Live { store, instance, memory }) @@ -248,6 +239,12 @@ impl Plugin { /// Calls `f` with at most `fuel` instructions, in slices, checking the deadline and the /// caller's cancellation ([`vectorcraft_doc::cancel`]) between them. + /// + /// wasmi pauses before an instruction that the store's fuel can't pay for, and charges nothing + /// for it. It charges a `memory.fill` or `memory.copy` for its whole length before writing, so + /// up to that cost can be left in the store at a pause. Each slice tops the store up to the + /// slice size, keeping that fuel, and a run ends with the instruction budget only when the + /// store's fuel and the rest of the budget together can't pay for the next instruction. fn call(&self, live: &mut Live, f: &TypedFunc, params: P, fuel: u64, deadline: &Deadline) -> Result { let mut left = fuel; let give = left.min(FUEL_SLICE); @@ -260,14 +257,16 @@ impl Plugin { TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())), TypedResumableCall::OutOfFuel(inv) => { let need = inv.required_fuel(); - if left == 0 || need > left { + let held = live.store.get_fuel().map_err(wasm_err)?; + let total = held.saturating_add(left); + if need > total { return Err(Error::Limit("instruction budget".into())); } deadline.check()?; vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; - let give = left.min(FUEL_SLICE.max(need)); - left -= give; - live.store.set_fuel(give).map_err(wasm_err)?; + let fill = total.min(FUEL_SLICE.max(need)).max(held); + left -= fill - held; + live.store.set_fuel(fill).map_err(wasm_err)?; call = inv.resume(&mut live.store).map_err(wasm_err)?; } } @@ -282,21 +281,39 @@ impl Plugin { /// [`Plugin::run`] with other time and instruction budgets (live effects get a shorter one). pub fn run_limited(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result> { - // wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must - // still not take the document down (native builds; wasm aborts on panic anyway). - let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits))); - run.map_err(|_| Error::Trap("the runtime panicked".into()))? + self.run_reporting(input, params, limits).0 } - fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result> { + /// [`Plugin::run_limited`], and what the run reported through the host imports: its last + /// progress report and its log lines, which also go to the app's log under its id. Inside a + /// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's + /// doesn't) and `vc_cancelled` sees the scope's Cancel. + pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result>, Report) { + let mut report = Report::default(); + // wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must + // still not take the document down (native builds; wasm aborts on panic anyway). + let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits, &mut report))); + (run.map_err(|_| Error::Trap("the runtime panicked".into())).and_then(|r| r), report) + } + + fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result> { let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2); total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?; - let deadline = Deadline::new(limits); - let mut live = self.instantiate()?; + let deadline = Deadline::new(limits.wall_time_ms); + let m = &self.manifest; + let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline); + let mut live = self.instantiate(run, m.permissions().contains(&Permission::Progress), report)?; + let out = self.run_live(&mut live, input, params, limits, &deadline); + *report = std::mem::take(&mut live.store.data_mut().run.report); + out + } + + /// Hands `input` and `params` to the instance `live` and runs `vc_run` → the output JSON bytes. + fn run_live(&self, live: &mut Live, input: &[u8], params: &[u8], limits: &Limits, deadline: &Deadline) -> Result> { let alloc: TypedFunc = live.func("vc_alloc")?; let run: TypedFunc = live.func("vc_run")?; let place = |live: &mut Live, bytes: &[u8]| -> Result { - let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, &deadline)? as u32 as usize; + let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, deadline)? as u32 as usize; if ptr == 0 { return Err(Error::Failed("vc_alloc returned 0 (out of memory)".into())); } @@ -305,10 +322,14 @@ impl Plugin { live.memory.data_mut(&mut live.store).get_mut(ptr..end).ok_or_else(outside)?.copy_from_slice(bytes); Ok(ptr) }; - let iptr = place(&mut live, input)?; - let pptr = place(&mut live, params)?; + let iptr = place(live, input)?; + let pptr = place(live, params)?; let fuel = limits.fuel_base.saturating_add((input.len() as u64).saturating_mul(limits.fuel_per_byte)); - let packed = self.call(&mut live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, &deadline)?; + let packed = self.call(live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, deadline)?; + // A cancelled run, or one past its time budget, changes nothing, also when the plug-in saw + // `vc_cancelled` and returned. + vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; + deadline.check()?; if packed < 0 { return Err(Error::Failed(format!("vc_run returned error code {packed}"))); } diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 0201e1a50..af041414a 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -42,11 +42,51 @@ pub fn returning(manifest: &str, output: &str) -> String { /// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then /// returns `output`. fn then_returning(manifest: &str, first: &str, output: &str) -> String { + with_imports(manifest, "", first, output) +} + +/// A plug-in with the module fields `imports` before the others, whose `vc_run` runs `first`, then +/// returns `output`. +pub fn with_imports(manifest: &str, imports: &str, first: &str, output: &str) -> String { let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len()); - let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output)); + let extra = format!(r#"{imports} (data (i32.const {OUTPUT_AT}) "{}")"#, string(output)); module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1) } +/// The host imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_progress`, +/// `$vc_cancelled` and `$vc_log`. +pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress (param i64 i64))) + (import "env" "vc_cancelled" (func $vc_cancelled (result i32))) + (import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#; + +/// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the +/// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then +/// returns `output`. +pub fn reporting(manifest: &str, first: &str, output: &str) -> String { + with_imports(manifest, HOST_IMPORTS, first, output) +} + +/// A plug-in of API level 2 that counts `steps` down, reporting each step's progress and checking +/// `vc_cancelled` before it: when that says the run was cancelled it returns `stopped`, else +/// `output` once the steps are done. +pub fn cooperating(manifest: &str, steps: u64, stopped: &str, output: &str) -> String { + let first = format!( + r#"(local $n i64) (local.set $n (i64.const {steps})) + (block $done (loop $step + (br_if $done (i64.le_s (local.get $n) (i64.const 0))) + (call $vc_progress (i64.sub (i64.const {steps}) (local.get $n)) (i64.const {steps})) + (if (call $vc_cancelled) (then (return (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const {STOPPED_AT}))))) + (local.set $n (i64.sub (local.get $n) (i64.const 1))) + (br $step)))"#, + len = stopped.len(), + ); + let imports = format!(r#"{HOST_IMPORTS} (data (i32.const {STOPPED_AT}) "{}")"#, string(stopped)); + with_imports(manifest, &imports, &first, output) +} + +/// Where [`cooperating`] keeps what it returns when it is cancelled. +const STOPPED_AT: usize = 0x8000; + /// A plug-in that counts `steps` down before it returns `output`. Each step costs a few /// instructions, so a test picks work that a light budget can't finish and a heavy one can, or /// that runs until it is cancelled. diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 5fbb694d5..f5b78d224 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; -use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, heavy_terms, registry, wat as tpl}; +use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, Report, heavy_terms, registry, wat as tpl}; /// The example Desaturate plug-in, built from `crates/plugins/example`. const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm"); @@ -155,6 +155,45 @@ fn budgets_stop_runaway_plugins() { assert_eq!(e, Error::Limit("instruction budget".into())); } +/// A filter that runs `op`, an 8 MiB bulk memory instruction costing about 131,000 instructions, +/// 160 times (about 21 million instructions in all), or forever when `endless`. +fn bulk_module(op: &str, endless: bool) -> Vec { + let bulk = match op { + "fill" => "(memory.fill (i32.const 1024) (i32.const 7) (i32.const 8388608))", + "copy" => "(memory.copy (i32.const 1024) (i32.const 2048) (i32.const 8387584))", + _ => panic!("no bulk operation `{op}`"), + }; + let run = if endless { + format!("(loop $work {bulk} (br $work)) {}", tpl::ECHO) + } else { + format!( + "(local $n i32) (local.set $n (i32.const 160)) \ + (loop $work {bulk} (local.set $n (i32.sub (local.get $n) (i32.const 1))) (br_if $work (local.get $n))) {}", + tpl::ECHO + ) + }; + build(&tpl::module(MANIFEST, &run, "", 130)) +} + +fn bulk_run(op: &str, endless: bool, fuel_base: u64) -> vectorcraft_plugins::Result> { + let p = Plugin::load(&bulk_module(op, endless), Limits { fuel_base, fuel_per_byte: 0, ..Limits::default() })?; + p.run(INPUT.as_bytes(), b"{}") +} + +/// A bulk memory instruction that the fuel left in a slice of the budget can't pay for pauses the +/// run without using that fuel, and the run keeps it: 160 fills or copies that cross the first +/// slice finish within 21 million instructions, though the pause left about 75,000 of them in the +/// slice. With fewer instructions than the work takes, or with a loop that never ends, the run +/// still ends with the instruction budget. +#[test] +fn a_bulk_memory_instruction_at_the_end_of_a_slice_keeps_the_run_s_fuel() { + for op in ["fill", "copy"] { + assert_eq!(bulk_run(op, false, 21_000_000), Ok(INPUT.as_bytes().to_vec()), "{op} within its budget"); + assert_eq!(bulk_run(op, false, 15_000_000), Err(Error::Limit("instruction budget".into())), "{op} over its budget"); + assert_eq!(bulk_run(op, true, 21_000_000), Err(Error::Limit("instruction budget".into())), "endless {op}"); + } +} + #[test] fn wall_clock_budget_stops_runaway_plugins() { let spin = ok_module("(loop $l (br $l)) (i64.const 0)"); @@ -308,6 +347,161 @@ fn folder_loading_skips_bad_files() { std::fs::remove_dir_all(&dir).ok(); } +/// A filter of plug-in API level 2 that may use the host imports. +const LEVEL2: &str = + r#"{"id":"test.level2","name":"Level 2","version":"1","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}"#; + +/// The error of loading `bytes`, as text. +fn load_err(bytes: &[u8]) -> String { + Plugin::load(bytes, Limits::default()).expect_err("the module must be refused").to_string() +} + +/// A module may import only the host's three functions, with the host's types, and only when its +/// manifest gives `api` 2 and lists `progress`; the error names the import it refuses. +#[test] +fn host_imports_are_checked_when_a_module_is_loaded() { + let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT)); + let unknown = with(LEVEL2, r#"(import "env" "vc_clock" (func (result i64)))"#); + assert!(load_err(&unknown).contains("imports `env.vc_clock`, which the host doesn't offer"), "{}", load_err(&unknown)); + let elsewhere = with(LEVEL2, r#"(import "vc" "vc_log" (func (param i32 i32 i32) (result i32)))"#); + assert!(load_err(&elsewhere).contains("imports `vc.vc_log`, which the host doesn't offer"), "{}", load_err(&elsewhere)); + let wrong_type = with(LEVEL2, r#"(import "env" "vc_progress" (func (param i32 i32)))"#); + assert!(load_err(&wrong_type).contains("imports `env.vc_progress` with another type than the host's: it is (param i64 i64)")); + let memory = with(LEVEL2, r#"(import "env" "vc_cancelled" (global i32))"#); + assert!(load_err(&memory).contains("imports `env.vc_cancelled` with another type"), "{}", load_err(&memory)); + let progress = r#"(import "env" "vc_progress" (func (param i64 i64)))"#; + let ungranted = r#"{"id":"test.ungranted","name":"U","kind":"filter","api":2,"permissions":["doc.read","doc.write"]}"#; + let says = "imports `env.vc_progress`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `progress`"; + for manifest in [ungranted, r#"{"id":"test.implied","name":"U","kind":"filter","api":2}"#, MANIFEST] { + let e = load_err(&with(manifest, progress)); + assert!(e.contains(says), "{manifest}: {e}"); + } + let p = Plugin::load(&with(LEVEL2, progress), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); + // A level-2 plug-in that imports nothing runs as a level-1 one does. + let plain = Plugin::load(&build(&tpl::returning(LEVEL2, INPUT)), Limits::default()).unwrap(); + assert_eq!(plain.run_reporting(INPUT.as_bytes(), b"{}", plain.limits()), (Ok(INPUT.as_bytes().to_vec()), Report::default())); +} + +/// Progress is clamped and passed on to the caller's sink as a share; the run's last report is kept. +#[test] +fn progress_reports_are_clamped_and_reach_the_caller() { + use std::{cell::RefCell, rc::Rc}; + let first = "(call $vc_progress (i64.const 50) (i64.const 10)) (call $vc_progress (i64.const -3) (i64.const 8)) (call $vc_progress (i64.const 3) (i64.const 8))"; + let p = Plugin::load(&build(&tpl::reporting(LEVEL2, first, INPUT)), Limits::default()).unwrap(); + let seen = Rc::new(RefCell::new(Vec::new())); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let token = vectorcraft_doc::cancel::CancelToken::new(); + let (out, report) = vectorcraft_doc::cancel::scope(&token, Some(sink), || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits())); + assert_eq!(out.unwrap(), INPUT.as_bytes()); + assert_eq!(report.progress, Some((3, 8))); + let step = vectorcraft_plugins::host::PROGRESS_STEP.to_string(); + assert_eq!(*seen.borrow(), [(10, 10, step.clone()), (0, 8, step.clone()), (3, 8, step)]); + // A live effect's progress stays its own: it is drawn as part of something else. + let effect = r#"{"id":"test.level2.effect","name":"E","kind":"effect","api":2,"permissions":["doc.read","effect.render","progress"]}"#; + let e = Plugin::load(&build(&tpl::reporting(effect, first, INPUT)), Limits::default()).unwrap(); + let quiet = Rc::new(RefCell::new(0)); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let quiet = quiet.clone(); + Rc::new(move |_, _, _| *quiet.borrow_mut() += 1) + }; + let (_, report) = vectorcraft_doc::cancel::scope(&token, Some(sink), || e.run_reporting(INPUT.as_bytes(), b"{}", e.limits())); + assert_eq!((report.progress, *quiet.borrow()), (Some((3, 8)), 0)); +} + +/// Cancel makes `vc_cancelled` return 1, and a plug-in that checks it stops at once; what it returns +/// then is discarded, so the run is `Cancelled`; past the time budget the same holds. One that +/// ignores it still ends at its instruction budget. Outside a cancelled scope it returns 0. +#[test] +fn cancel_reaches_a_cooperating_plugin_and_a_budget_ends_one_that_ignores_it() { + let p = + Plugin::load(&build(&tpl::cooperating(LEVEL2, 1 << 40, r#"{"stopped":1}"#, INPUT)), Limits { fuel_base: u64::MAX / 4, ..Limits::default() }) + .unwrap(); + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let (out, report) = vectorcraft_doc::cancel::scope(&token, None, || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits())); + assert_eq!(out, Err(Error::Cancelled), "what it returned after the Cancel is discarded"); + assert_eq!(report.progress, Some((0, 1 << 40)), "it saw the Cancel on its first step"); + // Not cancelled: it runs all its steps. + let short = Plugin::load(&build(&tpl::cooperating(LEVEL2, 1000, r#"{"stopped":1}"#, INPUT)), Limits::default()).unwrap(); + let (out, report) = short.run_reporting(INPUT.as_bytes(), b"{}", short.limits()); + assert_eq!((out.unwrap(), report.progress), (INPUT.as_bytes().to_vec(), Some((999, 1000)))); + // Past its time budget `vc_cancelled` returns 1 too, and what it returns then is discarded. + let late = + Plugin::load(&build(&tpl::cooperating(LEVEL2, 1000, r#"{"stopped":1}"#, INPUT)), Limits { wall_time_ms: 0, ..Limits::default() }).unwrap(); + let (out, report) = late.run_reporting(INPUT.as_bytes(), b"{}", late.limits()); + assert_eq!((out, report.progress), (Err(Error::Limit("time budget".into())), Some((0, 1000)))); + // One that never checks ends at its budget, cancelled or not. + let ignoring = build(&tpl::reporting(LEVEL2, "(loop $l (br $l))", INPUT)); + let p = Plugin::load(&ignoring, Limits { fuel_base: 1_000_000, ..Limits::default() }).unwrap(); + let r = vectorcraft_doc::cancel::scope(&token, None, || p.run(INPUT.as_bytes(), b"{}")); + assert_eq!(r, Err(Error::Limit("instruction budget".into()))); +} + +/// A log line longer than the cap is cut; lines past the run's budget are dropped, with one line +/// that says so; text outside memory, text that isn't UTF-8 and an unknown level are errors the +/// plug-in sees, never a panic. +#[test] +fn log_lines_are_capped_and_bad_text_is_an_error_the_plugin_sees() { + use vectorcraft_plugins::host::{self, LOG_BAD_LEVEL, LOG_NOT_UTF8, LOG_OUTSIDE_MEMORY, MAX_LOG_LINE, MAX_LOG_LINES}; + // `$text` at 0x2000 holds "hi", then 0xff, then 2000 bytes of "x". + let expect = |level: i32, ptr: &str, len: &str, want: i32| { + format!("(if (i32.ne (call $vc_log (i32.const {level}) {ptr} {len}) (i32.const {want})) (then unreachable))") + }; + let first = [ + expect(2, "(i32.const 0x2000)", "(i32.const 2)", 0), + expect(1, "(i32.const 0x2003)", "(i32.const 2000)", 0), + expect(2, "(i32.const -1)", "(i32.const 5)", LOG_OUTSIDE_MEMORY), + expect(2, "(i32.const 0x2000)", "(i32.const -1)", LOG_OUTSIDE_MEMORY), + expect(2, "(i32.mul (memory.size) (i32.const 65536))", "(i32.const 1)", LOG_OUTSIDE_MEMORY), + expect(2, "(i32.const 0x2000)", "(i32.const 3)", LOG_NOT_UTF8), + expect(9, "(i32.const 0x2000)", "(i32.const 2)", LOG_BAD_LEVEL), + ] + .concat(); + let data = format!(r#"(data (i32.const 0x2000) "hi\ff{}")"#, "x".repeat(2000)); + let imports = format!("{} {data}", tpl::HOST_IMPORTS); + let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, &first, INPUT)), Limits::default()).unwrap(); + let (out, report) = p.run_reporting(INPUT.as_bytes(), b"{}", p.limits()); + assert_eq!(out.unwrap(), INPUT.as_bytes(), "every call returned what it should"); + assert_eq!(report.lines.len(), 2); + assert_eq!(report.lines[0], vectorcraft_plugins::LogLine { level: vectorcraft_plugins::LogLevel::Info, text: "hi".into() }); + assert_eq!(report.lines[1].text, format!("{}…", "x".repeat(MAX_LOG_LINE - 2)), "cut at the cap, ellipsis included"); + // Past the budget: dropped, with one line that says so. + let flood = format!( + "(local $i i32) (loop $l (drop (call $vc_log (i32.const 2) (i32.const 0x2000) (i32.const 2))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br_if $l (i32.lt_u (local.get $i) (i32.const {}))))", + MAX_LOG_LINES * 3 + ); + let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, &flood, INPUT)), Limits::default()).unwrap(); + let (out, report) = p.run_reporting(INPUT.as_bytes(), b"{}", p.limits()); + assert!(out.is_ok() && report.truncated); + assert_eq!(report.lines.len(), MAX_LOG_LINES + 1); + assert_eq!(report.lines.iter().filter(|l| l.text == host::LOG_TRUNCATED).count(), 1); +} + +/// What a module's start function reports through the host imports before it fails is kept in the +/// run's report. This start function logs, reports progress and traps only when the run was +/// cancelled, so the module loads. +#[test] +fn what_a_start_function_reports_before_it_fails_is_kept() { + let start = r#"(data (i32.const 0x2000) "hi") + (func $start (if (call $vc_cancelled) (then + (drop (call $vc_log (i32.const 2) (i32.const 0x2000) (i32.const 2))) + (call $vc_progress (i64.const 1) (i64.const 4)) + unreachable))) + (start $start)"#; + let imports = format!("{} {start}", tpl::HOST_IMPORTS); + let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, "", INPUT)), Limits::default()).unwrap(); + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let (out, report) = vectorcraft_doc::cancel::scope(&token, None, || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits())); + assert!(out.is_err(), "{out:?}"); + assert_eq!(report.lines, [vectorcraft_plugins::LogLine { level: vectorcraft_plugins::LogLevel::Info, text: "hi".into() }]); + assert_eq!(report.progress, Some((1, 4))); +} + /// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped /// and reported. #[test] diff --git a/crates/render/src/bounds.rs b/crates/render/src/bounds.rs index dec8db1c5..5cb622e5d 100644 --- a/crates/render/src/bounds.rs +++ b/crates/render/src/bounds.rs @@ -34,3 +34,15 @@ pub fn drawn_bounds(doc: &Document, n: &Node) -> Option { pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { fx::drawn_outline(doc, n) } + +/// The outlines a drag draws for `nodes` with Real-time Drawing off: one for each object in them, +/// without uniting a group's members. `None` when the outlines' points and the objects evaluated to +/// make them come to more than `max_points`. +pub fn drawn_outlines<'a>(doc: &Document, nodes: impl IntoIterator, max_points: usize) -> Option> { + let symbols = doc.symbol_boxes(); + let (mut left, mut out) = (max_points, vec![]); + for n in nodes { + fx::drawn_outlines(doc, &symbols, n, &mut left, &mut out)?; + } + Some(out) +} diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs index 8516d5b0b..01fd6da01 100644 --- a/crates/render/src/changed.rs +++ b/crates/render/src/changed.rs @@ -131,8 +131,9 @@ impl Diff<'_> { } /// A group or layer that only its members changed in, without a clipping mask or effects of - /// its own that draw from all of them, changed where they did. Any other object that changed - /// counts whole, before and after. + /// its own that draw from all of them, changed where they did. A path or compound path that + /// only had anchors moved changed around them ([`moved_anchors`]). Any other object that + /// changed counts whole, before and after. fn node(&mut self, old: &Arc, new: &Arc) { if Arc::ptr_eq(old, new) { return; @@ -141,6 +142,15 @@ impl Diff<'_> { && same_but_members(old, new) { self.children(a, b); + } else if let Some((area, moved, anchors)) = moved_anchors(old, new) { + if moved <= FEW_MOVED && anchors >= MANY_ANCHORS { + self.add(area); + } else { + // The object's box before and after holds every pixel that changed too, and so + // does where the two overlap. + let whole = vectorcraft_geom::union_opt(crate::brush_fx::cull_bounds(&self.old, old), crate::brush_fx::cull_bounds(&self.new, new)); + self.add(area.zip(whole).map(|(a, w)| a.intersect(w))); + } } else { self.add_old(Some(old)); self.add_new(Some(new)); @@ -180,3 +190,145 @@ fn same_but_members(a: &Node, b: &Node) -> bool { }; strip(a) == strip(b) } + +/// The largest size of a coordinate, in points, that [`moved_anchors`] measures: past it, a path +/// drawn at a high zoom may stop being drawn at all (`crate::node_checks::drawable`). +const MOVED_LIMIT: f64 = 1e6; + +/// The most anchors of an object that may move for [`Diff::node`] to take the area round them +/// without also measuring the whole object before and after, which costs as much as its anchors. +const FEW_MOVED: usize = 64; + +/// The fewest anchors of an object that [`Diff::node`] leaves unmeasured whole when at most +/// [`FEW_MOVED`] of them moved. +const MANY_ANCHORS: usize = 1024; + +/// What path or compound path `old` changed in `new` when only anchors of its paths moved (in +/// place, with as many anchors in each subpath, each closed or open as before): the box of the +/// anchors and handles of every segment that starts or ends at a moved anchor, and of the line +/// that closes the fill of an open subpath at a moved end, before and after, grown by the farthest +/// its strokes reach (`None` when no anchor moved), how many anchors moved and how many it has. +/// Outside that box the path's segments, their fill and their strokes' joints stay as they were. +/// None when anything else changed, when it paints with something its whole shape decides +/// (effects, an opacity mask, a paint other than a solid color, dashes, a width profile, +/// arrowheads or a brush), or when a coordinate measured is not finite or larger in size than +/// [`MOVED_LIMIT`]. +fn moved_anchors(old: &Node, new: &Node) -> Option<(Option, usize, usize)> { + if !same_but_anchors(old, new) || !local_paint(old) { + return None; + } + let (area, moved, anchors) = match (&old.kind, &new.kind) { + (NodeKind::Path { path: a, .. }, NodeKind::Path { path: b, .. }) => moved_in(a, b)?, + (NodeKind::Compound { children: a, .. }, NodeKind::Compound { children: b, .. }) => { + let (mut area, mut moved, mut anchors) = (None, 0, 0); + for (x, y) in a.iter().zip(b) { + if Arc::ptr_eq(x, y) { + anchors += y.path_data().map_or(0, |p| p.anchor_count()); + continue; + } + match (&x.kind, &y.kind) { + (NodeKind::Path { path: p, .. }, NodeKind::Path { path: q, .. }) if same_but_anchors(x, y) => { + let (a, m, n) = moved_in(p, q)?; + (area, moved, anchors) = (vectorcraft_geom::union_opt(area, a), moved + m, anchors + n); + } + _ => return None, + } + } + (area, moved, anchors) + } + _ => return None, + }; + let reach = old.appearance.outset(); + Some((area.map(|r| r.inflate(reach, reach)), moved, anchors)) +} + +/// Are `a` and `b` the same but for where the anchors of their paths are: two paths, or two +/// compound paths with the same members by id, without extras, live shapes, guides or clipping? +fn same_but_anchors(a: &Node, b: &Node) -> bool { + let Node { id, name, visible, locked, opacity, blend, isolate, knockout, knockout_shape, appearance, kind, bbox_angle, extras } = a; + let kinds = match (kind, &b.kind) { + ( + NodeKind::Path { path: p, rule: r, live: None, clipping: false, guide: false }, + NodeKind::Path { path: q, rule: s, live: None, clipping: false, guide: false }, + ) => { + r == s + && p.subpaths.len() == q.subpaths.len() + && p.subpaths.iter().zip(&q.subpaths).all(|(x, y)| x.anchors.len() == y.anchors.len() && x.closed == y.closed) + } + (NodeKind::Compound { children: c, rule: r }, NodeKind::Compound { children: d, rule: s }) => { + r == s && c.len() == d.len() && c.iter().zip(d).all(|(x, y)| x.id == y.id) + } + _ => false, + }; + let none = vectorcraft_doc::Extras::default(); + kinds + && *id == b.id + && *name == b.name + && *visible == b.visible + && *locked == b.locked + && *opacity == b.opacity + && *blend == b.blend + && *isolate == b.isolate + && *knockout == b.knockout + && *knockout_shape == b.knockout_shape + && *appearance == b.appearance + && *bbox_angle == b.bbox_angle + && *extras == none + && b.extras == none +} + +/// Does `n` paint each pixel from the segments near it alone: no effects, every visible fill and +/// stroke a solid color, and no stroke with dashes, a width profile, pressure, tilt, bearing or +/// rotation, arrowheads, a brush or effects? +fn local_paint(n: &Node) -> bool { + use vectorcraft_doc::AppearanceItem; + let solid = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None); + n.appearance.effects.is_empty() + && !crate::fx::has_fx(n) + && !crate::fx::has_object_fx(n) + && n.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => solid(&f.paint) && f.effects.is_empty(), + AppearanceItem::Stroke(s) => { + solid(&s.paint) + && s.effects.is_empty() + && s.dash.is_none() + && s.profile.is_none() + && s.pressure.is_none() + && s.tilt.is_none() + && s.bearing.is_none() + && s.rotation.is_none() + && s.start_arrow.is_none() + && s.end_arrow.is_none() + && s.brush.is_none() + } + }) +} + +/// The box of the anchors and handles of the segments that start or end at an anchor that moved +/// from `a` to `b` (paths with the same subpaths, by anchor count and closing), and the ends of +/// the line that closes the fill of an open subpath at a moved end, in both (`None` when none +/// moved), how many moved and how many anchors `b` has. None when one of those coordinates is not +/// finite or larger in size than [`MOVED_LIMIT`]. +fn moved_in(a: &vectorcraft_geom::PathData, b: &vectorcraft_geom::PathData) -> Option<(Option, usize, usize)> { + let (mut area, mut moved, mut anchors): (Option, usize, usize) = (None, 0, 0); + for (x, y) in a.subpaths.iter().zip(&b.subpaths) { + let n = x.anchors.len(); + anchors += n; + for i in (0..n).filter(|&i| x.anchors[i] != y.anchors[i]) { + moved += 1; + // The anchors before and after it, round the end of an open subpath too: its fill + // closes it with a line from the last anchor to the first. + let (before, after) = ((i + n - 1) % n, (i + 1) % n); + for sp in [x, y] { + let (at, from, to) = (&sp.anchors[i], &sp.anchors[before], &sp.anchors[after]); + for p in [at.p, at.h_in, at.h_out, from.p, from.h_out, to.p, to.h_in] { + if !(p.x.abs() <= MOVED_LIMIT && p.y.abs() <= MOVED_LIMIT) { + return None; + } + area = Some(area.map_or(Rect::from_points(p, p), |r| r.union_pt(p))); + } + } + } + } + Some((area, moved, anchors)) +} diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index df71c6da9..f4643ab20 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -137,6 +137,13 @@ pub struct FrameStats { /// The filtered contents, shadows and glows the frame drew at a drag's lower resolution /// ([`RenderOptions::dragging`]). [`Self::moved`] counts them too. pub previewed: usize, + /// What the frame drew in a way that depends on the frame's size: effect rasters and filter + /// layers cropped to it or made at a lower resolution, blending groups drawn as layers past its + /// offscreen budget, gradient meshes drawn quad by quad past the mesh buffer, and every filter + /// layer drawn on a single-threaded context: on a single-threaded renderer, or offscreen in a + /// group or an effect's raster. A part of the frame rendered on its own can draw that art with + /// other pixels than the whole frame does. + pub view_sized: usize, /// How many threads composed the frame from its two ink planes, in a CMYK document that shows /// transparency; 0 for other frames. pub ink_threads: usize, diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 305ece398..abdf1666c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -70,7 +70,7 @@ mod renderer; #[cfg(test)] pub(crate) use bounds::visual_bounds; -pub(crate) use bounds::{cull_bounds, drawn_outline, painted_reach, path_cull_bounds}; +pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds}; #[cfg(test)] pub(crate) use entries::Drawn; pub(crate) use entries::{DocInputs, FxGeom, Reads, ShadowEntry}; diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 8bccf2c36..3e2692179 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -1,7 +1,8 @@ -use super::predicates::{has_fx, has_object_fx}; +use super::predicates::{has_fx, has_object_fx, is_container}; use super::raster::doc_raster_ppi; use super::{ - Affine, AppearanceItem, Arc, BezPath, Document, Effect, FillRule, GeomContext, Node, NodeKind, Rect, Renderer, Shape, SymbolBoxes, effects, + Affine, AppearanceItem, Arc, BezPath, Document, Effect, FillRule, GeomContext, Node, NodeKind, PathEl, Rect, Renderer, Shape, SymbolBoxes, + effects, }; fn node_bezpath(n: &Node) -> Option { @@ -232,6 +233,78 @@ pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRu } } +/// Adds the outlines of `n` to `out`, one per object and without uniting them: the visible members of +/// a group, layer or intertwine (a Shaper group's without its source art) and the evaluated art of a +/// blend, envelope, repeat, Pathfinder result or object with object effects. A gradient mesh adds its +/// mesh lines and a symbol instance its bounds, as the selection highlight shows them; any other +/// object, a clip group included, adds [`drawn_outline`]. Each outline's points and each evaluated +/// object are taken from `left`; `None` when they do not fit in it. +pub(crate) fn drawn_outlines(doc: &Document, symbols: &SymbolBoxes, n: &Node, left: &mut usize, out: &mut Vec) -> Option<()> { + if let Some(art) = effects::crop_marks_art(n) { + return match art.children().and_then(|c| c.first()) { + Some(object) => drawn_outlines(doc, symbols, object, left, out), + None => Some(()), + }; + } + if has_object_fx(n) { + let makes_art = effects::has_revolve(n) || effects::has_solid(n) || effects::addons::halftone::has_halftone(n); + // Effects that leave a mesh's shape alone evaluate it to its many colored pieces. + if let NodeKind::Mesh(m) = &n.kind + && !makes_art + && !effects::has_geometry(&n.appearance.effects) + { + return push_outline(Some(m.lines().to_bezpath()), left, out); + } + let mut object = n.clone(); + // A group's own fills and strokes repeat its members' geometry as more art. + if is_container(n) && !makes_art && !effects::has_item_geometry(n) { + object.appearance.items.clear(); + } + let art = Renderer::new().fx_art(doc, &Arc::new(object), false); + if art.children().is_some() && !has_object_fx(&art) { + return evaluated_outlines(doc, symbols, &art, left, out); + } + return push_outline(effects::clip_outline(&art).map(|(path, _)| path), left, out); + } + if effects::has_pathfinder(n) + && let Some(art) = effects::evaluate_container(Some(doc), n).filter(|art| !effects::has_pathfinder(art)) + { + return evaluated_outlines(doc, symbols, &art, left, out); + } + match &n.kind { + NodeKind::Group { children, clip: false } | NodeKind::Layer { children, clip: false, .. } | NodeKind::Intertwine { children } => { + for c in children.iter().skip(usize::from(n.extras().shaper.is_some())).filter(|c| c.visible) { + drawn_outlines(doc, symbols, c, left, out)?; + } + Some(()) + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { + evaluated_outlines(doc, symbols, &effects::expanded_live_group(Some(doc), n), left, out) + } + NodeKind::Mesh(m) => push_outline(Some(m.lines().to_bezpath()), left, out), + NodeKind::SymbolInstance { .. } => { + push_outline(n.geometric_bounds_with(symbols).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath()), left, out) + } + _ => push_outline(drawn_outline(doc, n).map(|(path, _)| path), left, out), + } +} + +/// [`drawn_outlines`] of `art`, the evaluated form of a live object or an object with effects, with +/// each of its objects taken from `left`. +fn evaluated_outlines(doc: &Document, symbols: &SymbolBoxes, art: &Node, left: &mut usize, out: &mut Vec) -> Option<()> { + *left = left.checked_sub(art.children().map_or(0, Vec::len))?; + drawn_outlines(doc, symbols, art, left, out) +} + +/// Adds `path` to `out` with its points taken from `left`; `None` when they do not fit in it. +fn push_outline(path: Option, left: &mut usize, out: &mut Vec) -> Option<()> { + if let Some(path) = path { + *left = left.checked_sub(path.elements().iter().filter(|el| !matches!(el, PathEl::ClosePath)).count())?; + out.push(path); + } + Some(()) +} + /// How far raster effects `fx` paint beyond `b`, each spreading what the ones before it paint /// ([`spread`]). fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 { diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index 3f13d1b3e..b544be8d1 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -100,6 +100,7 @@ impl Renderer { // The cached raster: of the whole content at full size at this zoom, else of this view (and // the margin around it). let whole = fits && k >= 1.0; + self.stats.view_sized += usize::from(!whole); let in_view = (!whole).then(|| ViewCrop::of(f.view, room)); let stamp = self.stamp; let channels = match f.ink.base() { @@ -574,10 +575,15 @@ impl Renderer { composite: Option<(vectorcraft_doc::color::BlendMode, f32)>, draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame, Option<(vectorcraft_doc::color::BlendMode, f32)>), ) { + // A filter layer's coarser grid starts where its context does: at the corner of a context + // drawn single-threaded, and at the frame's edge for a region cropped to the frame. + let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())); + self.stats.view_sized += usize::from(!f.mt || f.view.transform_rect_bbox(reach).union(screen) != screen); if !f.mt { return self.inside_layer(|r| draw(r, ctx, f, composite)); } let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return }; + self.stats.view_sized += usize::from(k < 1.0); // During a drag, the region holds at most `DRAG_PIXEL_BUDGET` pixels (#2558). let drag = || filter_region_within(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px, DRAG_PIXEL_BUDGET); let (x0, y0, w, h, k) = match f.opts.dragging.then(drag).flatten().filter(|r| r.4 < k) { diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index fd8d68a98..6b7aa49f7 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -10,6 +10,26 @@ use super::{ effects, fill_rule, peniko, }; +#[cfg(test)] +thread_local! { + /// How many silhouettes of drop shadows and outer glows renderers on this thread have drawn: + /// one per shadow or glow raster made ([`Renderer::shadow_raster`]) and per one drawn through a + /// filter layer ([`Renderer::draw_shadow_filter`]). + static SILHOUETTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`SILHOUETTES`] of this thread. +#[cfg(test)] +pub(super) fn silhouettes() -> u64 { + SILHOUETTES.get() +} + +/// Adds one to [`SILHOUETTES`]. +#[cfg(test)] +fn note_silhouette() { + SILHOUETTES.set(SILHOUETTES.get().saturating_add(1)); +} + impl Renderer { /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) as a cached raster: the /// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU, @@ -125,6 +145,8 @@ impl Renderer { let decoded = crate::paint::decode_failures(); let outer_image_left_out = self.image_left_out.replace(false); let pattern_images_left_out = crate::pattern::images_left_out(); + #[cfg(test)] + note_silhouette(); self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); let failed = crate::paint::decode_failures() - decoded; let image_left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out; @@ -183,6 +205,7 @@ impl Renderer { (image, x0, y0, scale, moved, cropped) } }; + self.stats.view_sized += usize::from(cropped.is_some() || scale != 1.0); Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped }) } @@ -229,6 +252,8 @@ impl Renderer { // The offset moves the content rather than the filter: vello_cpu drops layer content // that lies entirely outside the viewport before filtering. let reach = reach + Vec2::new(dx, dy); + #[cfg(test)] + note_silhouette(); self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { c.set_transform(fr.view); c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 74ff632d6..d964a5244 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -467,9 +467,10 @@ fn a_glow_too_large_for_a_full_size_raster_keeps_its_solid_silhouette() { } #[test] -fn thirty_stacked_glows_draw_quickly_cached_or_not() { +fn thirty_stacked_glows_draw_thirty_silhouettes_cached_or_not() { // GUARDS: each glow is of the content with the glows before it; drawing those again for every glow costs 2^n. // A shape draws through the shadow cache, a symbol instance without it (the filter layers). + // Each glow's silhouette is drawn once. let glows: Vec<(&str, serde_json::Value)> = (0..30).map(|k| ("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 20, "blur": 1 + k % 4}))).collect(); let shape = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, glows.clone()); @@ -486,9 +487,10 @@ fn thirty_stacked_glows_draw_quickly_cached_or_not() { let l = instanced.layers[0].id; instanced.insert(Some(l), 0, instance).unwrap(); for d in [&shape, &instanced] { - let start = std::time::Instant::now(); + let before = super::renderer::silhouettes(); let p = render(d).pixel(25, 50); - assert!(start.elapsed() < std::time::Duration::from_secs(20), "{:?}", start.elapsed()); + let drawn = super::renderer::silhouettes() - before; + assert_eq!(drawn, 30, "silhouettes drawn for 30 glows"); assert!(p[1] < 250, "the glows show: {p:?}"); } } diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index 3b2a8c945..609771c66 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -84,6 +84,7 @@ impl Renderer { if c.mask.is_some() || self.offscreen_fits(ctx, r, c.isolated) { return self.group_offscreen(ctx, f, c, r, draw); } + self.stats.view_sized += 1; } if c.mask.is_some() { // Its backdrop out of reach (or not needed), a masked group starts from transparency. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 46a0ca64a..68020dcfa 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -224,7 +224,7 @@ mod renderer; mod text_geom; mod threads; -pub use bounds::{drawn_bounds, drawn_outline, effect_bounds, painted_bounds}; +pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds}; pub use frame::FrameStats; use frame::{ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey}; #[cfg(test)] diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index e7e881a4f..8abf54070 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -582,6 +582,7 @@ impl Renderer { ctx.set_transform(f.view); return; } + self.stats.view_sized += 1; ctx.set_transform(f.view); // Where a patch narrows to slivers, many quads share each pixel, each covering a little of it: their edges // composite to part coverage, and hard edges drop each one. An opaque patch is backed by its own outline in diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index e22d4df5e..490703aa2 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -31,6 +31,8 @@ pub(crate) use paint::{CROP_STROKES, crop_line}; impl Renderer { pub fn new() -> Self { + // Envelopes map type from its glyphs here too, without an engine session. + vectorcraft_text::register_envelope_ink(); Self { texts: FrameCache::new(lru::TEXT_BUDGET, 1024), text_ids: PtrMap::default(), @@ -91,6 +93,7 @@ impl Renderer { /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { + let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); self.render_as(doc, width, height, view, opts, false) } @@ -315,6 +318,7 @@ impl Renderer { pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec { let (w, h) = region_pixels(region, scale); let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); self.render_as(doc, w, h, view, opts, true).pixels } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 82a1374da..f7799d02a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1333,7 +1333,7 @@ fn invalid_dash_pattern_strokes_solid() { let d = line(Some(pattern.clone())); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || tx.send(render(&d)).ok()); - let img = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("rendering dash {pattern:?} did not finish")); + let img = rx.recv_timeout(std::time::Duration::from_secs(300)).unwrap_or_else(|_| panic!("rendering dash {pattern:?} did not finish")); assert_eq!(img.pixels, solid.pixels, "dash {pattern:?}"); } } diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index e5f1f913d..459397149 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -3,7 +3,7 @@ use crate::*; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::Appearance; -use vectorcraft_geom::{Point, shapes}; +use vectorcraft_geom::{Anchor, PathData, Point, SubPath, Vec2, shapes}; const W: u32 = 160; const H: u32 = 120; @@ -258,3 +258,166 @@ fn moving_stretched_type_changes_where_its_stretched_strokes_reach() { new.node_mut(text).unwrap().transform(Affine::translate((9.0, 7.0)), false); area(redraw(&old, &new)); } + +/// `n` anchors round an ellipse at (55, 40) with radii `rx` and `ry` and a ripple, every third a +/// corner and the others smooth with handles along the ellipse. +fn wavy(n: usize, rx: f64, ry: f64) -> SubPath { + let anchors = (0..n) + .map(|i| { + let t = i as f64 / n as f64 * std::f64::consts::TAU; + let k = 1.0 + 0.06 * (t * 12.0).sin(); + let p = Point::new(55.0 + rx * k * t.cos(), 40.0 + ry * k * t.sin()); + if i % 3 == 0 { + return Anchor::corner(p); + } + let along = Vec2::new(-rx * t.sin(), ry * t.cos()) * (0.4 / n as f64); + Anchor { h_in: p - along, h_out: p + along, ..Anchor::corner(p) } + }) + .collect(); + SubPath::new(anchors, true) +} + +/// The seven-pointed star {7/3} round (55, 40), each edge split by 39 anchors on it: its edges +/// cross, so parts of it are covered more than once. +fn star() -> SubPath { + let tip = |i: usize| { + let t = (i * 3 % 7) as f64 / 7.0 * std::f64::consts::TAU; + Point::new(55.0 + 45.0 * t.sin(), 40.0 - 35.0 * t.cos()) + }; + let anchors = (0..7).flat_map(|i| (0..40).map(move |j| Anchor::corner(tip(i).lerp(tip(i + 1), j as f64 / 40.0)))).collect(); + SubPath::new(anchors, true) +} + +/// A document with path `sub` filled with `rule`, stroked `stroke` points wide when that is more +/// than 0, and its id. +fn long_path(sub: SubPath, rule: FillRule, stroke: f64) -> (Document, NodeId) { + let mut d = Document::new(110.0, 80.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None }; + let mut n = Node::path(id, PathData::single(sub), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.5, 0.8)), line, stroke)); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = rule; + } + let id = d.insert(Some(l), 0, n).unwrap(); + (d, id) +} + +/// `d` with anchor `i` of the first subpath of path `id` and its handles moved by `by`. +fn move_anchor(d: &Document, id: NodeId, i: usize, by: Vec2) -> Document { + let mut d = d.clone(); + if let Some(NodeKind::Path { path, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + let a = &mut path.subpaths[0].anchors[i]; + (a.p, a.h_in, a.h_out) = (a.p + by, a.h_in + by, a.h_out + by); + } + d +} + +/// Moving one anchor of a long filled path changes the fill only round the segments at that +/// anchor, with either fill rule, a smooth or a corner anchor, and a closing segment. +#[test] +fn moving_an_anchor_of_a_long_path_changes_only_round_its_segments() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let (old, id) = long_path(wavy(2000, 45.0, 32.0), rule, 0.0); + for i in [0, 700, 1001] { + let a = area(redraw(&old, &move_anchor(&old, id, i, Vec2::new(4.0, -3.0)))); + assert!(a.width() < 8.0 && a.height() < 8.0, "{rule:?}, anchor {i}: {a:?}"); + } + } +} + +/// The fill of an open path closes it with a line from its last anchor to its first: moving an +/// end changes where that line was and is too. +#[test] +fn moving_an_end_of_an_open_filled_path_changes_the_line_that_closes_its_fill() { + let mut half = wavy(3000, 45.0, 32.0); + half.anchors.truncate(1500); + half.closed = false; + let (old, id) = long_path(half, FillRule::NonZero, 0.0); + for i in [0, 1499] { + redraw(&old, &move_anchor(&old, id, i, Vec2::new(0.0, 12.0))); + } +} + +/// Where a path covers its middle twice, the two fill rules draw it differently: an anchor moved +/// across that part changes only round its segments. +#[test] +fn moving_an_anchor_where_a_path_crosses_itself_changes_only_round_its_segments() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let (old, id) = long_path(star(), rule, 0.0); + let a = area(redraw(&old, &move_anchor(&old, id, 20, Vec2::new(-6.0, 5.0)))); + assert!(a.width() < 12.0 && a.height() < 12.0, "{rule:?}: {a:?}"); + } +} + +/// A stroke with miter joints changes round the moved anchor's segments and as far as its spikes +/// reach from them, and the open end of a stroke with square caps as far as its cap's corners. +#[test] +fn moving_an_anchor_of_a_stroked_path_changes_as_far_as_its_joints_reach() { + let (old, id) = long_path(star(), FillRule::NonZero, 1.5); + let a = area(redraw(&old, &move_anchor(&old, id, 60, Vec2::new(3.0, 4.0)))); + assert!(a.width() < 30.0, "{a:?}"); + let mut open = star(); + open.closed = false; + let (old, id) = long_path(open, FillRule::NonZero, 3.0); + let mut sq = old.clone(); + if let Some(AppearanceItem::Stroke(s)) = sq.node_mut(id).unwrap().appearance.items.get_mut(1) { + s.cap = vectorcraft_doc::LineCap::Square; + } + let a = area(redraw(&sq, &move_anchor(&sq, id, 279, Vec2::new(5.0, 2.0)))); + assert!(a.width() < 50.0, "{a:?}"); +} + +/// A compound path with a hole changes only round the moved anchor of the member it moved in. +#[test] +fn moving_an_anchor_of_a_compound_path_changes_only_round_its_segments() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let (outer, inner) = (old.alloc_id(), old.alloc_id()); + let mut hole = wavy(600, 25.0, 15.0); + if rule == FillRule::NonZero { + // Wound the other way, so it is a hole by either rule. + hole.anchors.reverse(); + for a in &mut hole.anchors { + std::mem::swap(&mut a.h_in, &mut a.h_out); + } + } + let members = vec![ + Arc::new(Node::path(outer, PathData::single(wavy(1500, 45.0, 32.0)), Appearance::default())), + Arc::new(Node::path(inner, PathData::single(hole), Appearance::default())), + ]; + let mut compound = Node::new(old.alloc_id(), NodeKind::Compound { children: members, rule }); + compound.appearance = Appearance::basic(Paint::solid(Color::rgb(0.8, 0.3, 0.1)), Paint::None, 0.0).into(); + old.insert(Some(l), 0, compound).unwrap(); + let new = move_anchor(&old, inner, 150, Vec2::new(3.0, 3.0)); + let a = area(redraw(&old, &new)); + assert!(a.width() < 8.0 && a.height() < 8.0, "{rule:?}: {a:?}"); + } +} + +/// A gradient fill and a dashed stroke are laid out by the whole path: moving one anchor changes +/// all of it, as does adding an anchor. +#[test] +fn a_path_painted_by_its_whole_shape_changes_whole_when_an_anchor_moves() { + let (plain, id) = long_path(wavy(400, 45.0, 32.0), FillRule::NonZero, 1.0); + let mut graded = plain.clone(); + let stops = vec![vectorcraft_color::GradientStop::new(0.0, Color::BLACK), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)]; + graded.node_mut(id).unwrap().appearance.fill_mut().unwrap().paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new( + vectorcraft_color::Gradient::new(vectorcraft_color::GradientKind::Linear, stops), + ))); + let mut dashed = plain.clone(); + if let Some(AppearanceItem::Stroke(s)) = dashed.node_mut(id).unwrap().appearance.items.get_mut(1) { + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![3.0, 2.0], ..Default::default() }); + } + for old in [graded, dashed] { + let a = area(redraw(&old, &move_anchor(&old, id, 100, Vec2::new(2.0, 2.0)))); + assert!(a.width() > 80.0 && a.height() > 60.0, "{a:?}"); + } + let mut added = plain.clone(); + if let Some(NodeKind::Path { path, .. }) = added.node_mut(id).map(|n| &mut n.kind) { + path.subpaths[0].anchors.insert(10, Anchor::corner(Point::new(50.0, 40.0))); + } + let a = area(redraw(&plain, &added)); + assert!(a.width() > 80.0 && a.height() > 60.0, "{a:?}"); +} diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index c98af03d3..2ca5b5978 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -320,3 +320,35 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() { assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it"); } } + +/// Without an engine session, a renderer maps an envelope of type from its glyph outline bounds, +/// and measures the type again when its font becomes available. +#[test] +fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() { + let _adding = adding_fonts(); + const FAMILY: &str = "Mapped Serif 4"; + let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() }; + let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let envelope = Node::new( + vectorcraft_doc::NodeId(1), + NodeKind::Envelope { + content: vec![Arc::new(Node::new(vectorcraft_doc::NodeId(2), NodeKind::Text(Box::new(t.clone()))))], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }, + ); + let ink = || t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()); + let src = || vectorcraft_doc::live::EnvelopeMap::of(&envelope).unwrap().src(); + let off = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + let _ = Renderer::new(); + let before = ink(); + assert!(off(src(), before) < 1e-6, "{:?} vs {before:?}", src()); + assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0); + let after = ink(); + assert!(off(before, after) > 0.5, "the type draws in the font that arrived: {before:?} vs {after:?}"); + assert!(off(src(), after) < 1e-6, "{:?} vs {after:?}", src()); +} diff --git a/crates/render/src/tests/isolation.rs b/crates/render/src/tests/isolation.rs index d88736785..3aaf79730 100644 --- a/crates/render/src/tests/isolation.rs +++ b/crates/render/src/tests/isolation.rs @@ -498,4 +498,13 @@ fn nested_isolated_blending_groups_stay_within_the_offscreen_budget() { assert!(images <= budget, "{images} pixels drawn from offscreen groups, budget {budget}"); assert_near(frame.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the groups"); assert_near(frame.pixel(10, 10), B, "outside the groups"); + let mut r = Renderer::new(); + render_with(&mut r, &d, &RenderOptions::default()); + assert!(r.stats.view_sized > 0, "levels past the budget depend on the frame's size"); + let within = doc(|d| { + let n = multiply(d); + group(d, vec![n], 1.0, Blend::Multiply, true) + }); + render_with(&mut r, &within, &RenderOptions::default()); + assert_eq!(r.stats.view_sized, 0, "a group within the budget"); } diff --git a/crates/render/src/tests/viewraster.rs b/crates/render/src/tests/viewraster.rs index 70ea0a8b6..47fd4d946 100644 --- a/crates/render/src/tests/viewraster.rs +++ b/crates/render/src/tests/viewraster.rs @@ -180,6 +180,72 @@ fn pans_within_a_cropped_rasters_margin_draw_it_kept() { } } +/// A frame counts the art it draws in a way that depends on its size: an effect raster cropped to +/// it or made at a lower resolution, drawn from the raster kept as well as made; a filter layer +/// cropped to it or made at a lower resolution; any filter layer drawn on a single-threaded context, +/// as on a single-threaded renderer or in a group drawn offscreen; and a mesh past the mesh buffer. +/// Art with a raster of its own whole, and a filter layer that stays inside the frame, are not +/// counted. +#[test] +fn a_frame_counts_the_art_it_draws_by_its_size() { + let effect = |id: &str, params: serde_json::Value| backdrop(&[(id, params)]).0; + let mesh = |side: f64| { + let mut d = Document::new(595.0, 842.0); + let m = vectorcraft_doc::GradientMesh::from_surface(2, 2, &|u, v| vectorcraft_geom::Point::new(u * side, v * side), &|u, v| { + Color::rgb(u as f32, v as f32, 0.5) + }); + let layer = d.layers[0].id; + let node = Node::new(d.alloc_id(), NodeKind::Mesh(m)); + d.insert(Some(layer), 0, node).unwrap(); + d + }; + let blur = effect("blur.gaussian", json!({"radius": 8})); + let shadow = effect("stylize.dropShadow", json!({"blur": 8, "x": 4, "y": 4})); + let outer_glow = effect("stylize.outerGlow", json!({"blur": 8})); + let glow = effect("stylize.innerGlow", json!({"blur": 8})); + let wide_glow = effect("stylize.innerGlow", json!({"blur": 2000})); + let group_glow = { + let mut d = Document::new(595.0, 842.0); + let basic = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let wide = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 595.0, 842.0)), basic(Color::rgb(0.9, 0.8, 0.3))); + let mut child = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(200.0, 200.0, 330.0, 500.0)), basic(Color::rgb(0.1, 0.3, 0.9))); + child.appearance.effects = vec![Effect { id: "stylize.innerGlow".into(), params: json!({"blur": 40}), visible: true }]; + child.blend = vectorcraft_doc::color::BlendMode::Multiply; + let mut group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(wide), std::sync::Arc::new(child)]); + group.isolate = true; + let layer = d.layers[0].id; + d.insert(Some(layer), 0, group).unwrap(); + d + }; + let cases = [ + ("a whole blur raster", &blur, 1.0, (640, 900), 2, false), + ("a blur raster cropped to the frame", &blur, 10.0, (256, 128), 2, true), + ("a blur raster at a lower resolution", &blur, 5.0, (3200, 4400), 2, true), + ("a whole shadow raster", &shadow, 1.0, (640, 900), 2, false), + ("a shadow raster cropped to the frame", &shadow, 40.0, (256, 128), 2, true), + ("an outer glow raster at a lower resolution", &outer_glow, 5.0, (3200, 4400), 2, true), + ("a filter layer inside the frame", &glow, 1.0, (640, 900), 2, false), + ("a filter layer past the frame's edge", &glow, 1.0, (256, 128), 2, true), + ("a filter layer at a lower resolution", &wide_glow, 1.0, (640, 900), 2, true), + ("a filter layer on a single-threaded renderer", &glow, 1.0, (640, 900), 0, true), + ("a filter layer in a group drawn offscreen", &group_glow, 1.0, (640, 900), 2, true), + ]; + for (name, d, zoom, size, threads, counted) in cases { + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + r.threads = threads; + r.render(d, size.0, size.1, view(zoom, size), &opts); + assert_eq!(r.stats.view_sized > 0, counted, "{name}"); + r.render(d, size.0, size.1, view(zoom, size), &opts); + assert_eq!(r.stats.view_sized > 0, counted, "{name}, drawn again"); + } + let mut r = Renderer::new(); + r.render(&mesh(100.0), 2100, 2100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(r.stats.view_sized, 0, "a mesh within the mesh buffer"); + r.render(&mesh(3000.0), 2100, 2100, Affine::IDENTITY, &RenderOptions::default()); + assert!(r.stats.view_sized > 0, "a mesh past the mesh buffer"); +} + /// Scrolls by amounts that are not whole pixels, as the wheel and a trackpad give them, moved in whole pixels with the part left /// over carried to the next, as the canvas moves them: after the scroll that makes the Drop Shadow's cropped raster /// again with its margin, every later scroll draws it kept. The same scrolls moved as given make it again in every diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 0d65dfbff..5e3c9496a 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -147,7 +147,7 @@ pub(crate) fn text_geom(t: &TextObject) -> TextGeom { /// Glyph geometry of `t`; `snap` (text space → device pixels) puts the glyphs on whole pixels. pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { let db = vectorcraft_text::FontDb::global(); - let mut layout = vectorcraft_text::layout(db, t); + let mut layout = vectorcraft_text::take_kept_layout(t).unwrap_or_else(|| vectorcraft_text::layout(db, t)); if let Some(xf) = snap { layout.snap_to_pixels(xf); } diff --git a/crates/render/tests/envelope_ink.rs b/crates/render/tests/envelope_ink.rs new file mode 100644 index 000000000..7622143f5 --- /dev/null +++ b/crates/render/tests/envelope_ink.rs @@ -0,0 +1,30 @@ +//! A renderer made without an engine session makes envelopes measure type by its glyph outlines. +//! One test in its own process, so nothing else has registered the measure before the renderer is +//! made. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Point, Rect}; + +/// The largest distance between the edges of `a` and `b`. +fn off(a: Rect, b: Rect) -> f64 { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())) +} + +#[test] +fn a_new_renderer_measures_type_in_envelopes_by_its_glyphs() { + let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() }); + let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())))); + let laid = n.geometric_bounds().unwrap(); + let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()); + assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}"); + let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap(); + assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}"); + + let _ = vectorcraft_render::Renderer::new(); + let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap(); + assert!(off(after, ink) < 1e-6, "after a renderer is made, type is measured by its glyphs: {after:?} vs {ink:?}"); +} diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index 30432771a..3593a870b 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -120,6 +120,18 @@ fn is_ident(c: char) -> bool { /// How deep `:not()`s may nest (real style sheets nest one or two). const MAX_NOT_NESTING: usize = 8; +#[cfg(test)] +thread_local! { + /// How many times [`Selector::matches`] has tested a selector against an element on this thread. + static SELECTOR_TESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`SELECTOR_TESTS`] of this thread. +#[cfg(test)] +fn selector_tests() -> u64 { + SELECTOR_TESTS.get() +} + impl Selector { /// `None` for selectors this cascade doesn't support (they never match). fn parse(s: &str) -> Option { @@ -273,6 +285,8 @@ impl Selector { memo.insert(key, hit); hit } + #[cfg(test)] + SELECTOR_TESTS.set(SELECTOR_TESTS.get().saturating_add(1)); go(&self.parts, n, &mut HashMap::new()) } } diff --git a/crates/svg/src/import/css/tests.rs b/crates/svg/src/import/css/tests.rs index 89b519858..d4704192f 100644 --- a/crates/svg/src/import/css/tests.rs +++ b/crates/svg/src/import/css/tests.rs @@ -249,11 +249,11 @@ proptest! { } } -/// 50,000 class rules give 50,000 elements, each with one of the classes, their fill within -/// 20 s. +/// 5,000 class rules give 5,000 elements, each with one of the classes, their fill, testing +/// about one rule per element. #[test] -fn styling_50000_elements_with_50000_rules_finishes_within_20_s() { - const N: usize = 50_000; +fn styling_5000_elements_with_5000_rules_tests_about_one_rule_each() { + const N: usize = 5_000; let rules: String = (0..N).map(|i| format!(".c{i} {{ fill: #{i:06x} }}\n")).collect(); let rects: String = (0..N).map(|i| format!(r#""#)).collect(); let svg = format!(r#"{rects}"#); @@ -262,9 +262,12 @@ fn styling_50000_elements_with_50000_rules_finishes_within_20_s() { let doc = roxmltree::Document::parse(&svg).unwrap(); let st = Styles::new(&doc); let fills: Vec> = doc.descendants().filter(|n| n.tag_name().name() == "rect").map(|n| st.own(n, "fill")).collect(); - tx.send(fills).ok(); + tx.send((fills, selector_tests())).ok(); }); - let fills = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("styling {N} elements did not finish within 20 s")); + let (fills, tests) = + rx.recv_timeout(std::time::Duration::from_secs(300)).unwrap_or_else(|_| panic!("styling {N} elements did not finish within 300 s")); + // Testing every rule against every element would test N × N selectors. + assert!(tests <= 2 * N as u64, "styling {N} elements tested {tests} selectors"); assert_eq!(fills.len(), N); for (i, fill) in fills.iter().enumerate() { assert_eq!(fill.as_deref(), Some(format!("#{i:06x}").as_str())); diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index b6d488c9e..9de3bebcd 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use usvg::{ImageHrefResolver, ImageKind, roxmltree}; use vectorcraft_doc::LinkInfo; +use vectorcraft_doc::links::missing_placeholder; use super::{Edits, depth, href}; use crate::{ImportOptions, MAX_ELEMENT_DEPTH, MAX_SVGZ, SvgError}; @@ -62,22 +63,6 @@ pub(in crate::import) struct Linked { pub missing: bool, } -/// The side of the placeholder image, in pixels. -const MARKER: u32 = 64; - -/// The placeholder of a missing linked image: a light tile, crossed out (drawn here). -fn marker() -> Option> { - let last = MARKER - 1; - let img = image::RgbaImage::from_fn(MARKER, MARKER, |x, y| { - let edge = x == 0 || y == 0 || x == last || y == last; - let cross = x.abs_diff(y) <= 1 || (x + y).abs_diff(last) <= 1; - image::Rgba(if edge || cross { [200, 60, 60, 255] } else { [236, 236, 236, 255] }) - }); - let mut png = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - Some(png) -} - /// `s` with `%XX` escapes decoded (an href is a URL). fn percent_decoded(s: &str) -> String { if !s.contains('%') { @@ -159,7 +144,7 @@ impl Files { }; let source = source.or_else(|why| { warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked")); - let bytes = Arc::new(placeholder.get_or_insert_with(marker).clone().ok_or(why)?); + let bytes = Arc::new(placeholder.get_or_insert_with(missing_placeholder).clone().ok_or(why)?); files.links.insert(key(&bytes), Linked { link: LinkInfo::new(path), missing: true }); Ok::<_, &str>(Source::Raster(ImageKind::PNG, bytes)) }); @@ -479,10 +464,4 @@ mod tests { assert!(!read("2147483647")); assert!(read("1")); } - - #[test] - fn the_placeholder_is_a_png() { - let png = marker().unwrap(); - assert_eq!(image::load_from_memory(&png).unwrap().width(), MARKER); - } } diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 49bcfaab7..55cf0aa91 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -26,6 +26,13 @@ pub(super) struct SymbolMade { reuse: Reuse, } +/// The image key of a linked image of the file at `path` that shows `bytes` (the file's pixels or +/// a placeholder): the key of the bytes and the path's. Images of other files that show the same +/// pixels have keys of their own, and reading one file's pixels in changes that file's images only. +fn linked_key(bytes: &[u8], path: &str) -> String { + format!("img-{:016x}-{}", fnv1a(bytes), vectorcraft_doc::links::hash_bytes(path.as_bytes())) +} + impl Importer { pub(super) fn warn(&mut self, s: String) { if !self.warnings.contains(&s) { @@ -721,11 +728,14 @@ impl Importer { .ok() .and_then(|r| r.into_dimensions().ok()) .unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32)); - let key = format!("img-{:016x}", fnv1a(bytes)); // A linked file keeps a preview; a placeholder is all a missing one has (its preview). let linked = self.files.linked(i.kind()); let missing = linked.is_some_and(|l| l.missing); let link = linked.map(|l| l.link.clone()); + let key = match &link { + Some(l) => linked_key(bytes, &l.path), + None => format!("img-{:016x}", fnv1a(bytes)), + }; self.doc.images.entry(key.clone()).or_insert_with(|| { let blob = ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: missing.then(|| bytes.clone()) }; if link.is_some() && !missing { blob.with_proxy() } else { blob } diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 13518a6fd..7857ba07c 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -105,6 +105,40 @@ fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() { assert!(!w.is_empty()); } +/// Missing files show the same placeholder, each under a key of its own: the images of one file +/// share a key, and those of another file have a different one. +#[test] +fn each_missing_file_has_its_own_placeholder_key() { + let dir = folder("missing-keys"); + let body = r#""#; + let (d, _) = open_in(&dir, body); + let [(_, a), (_, b), (_, a2)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + assert_eq!(Path::new(&a.link.as_ref().unwrap().path), dir.join("img").join("a.png")); + assert_eq!(Path::new(&b.link.as_ref().unwrap().path), dir.join("img").join("b.png")); + assert_ne!(a.key, b.key, "two files, two keys"); + assert_eq!(a.key, a2.key, "one file, one key"); + assert!(d.images[&a.key].is_proxy() && d.images[&b.key].is_proxy(), "placeholders, not the files"); + assert_eq!(d.images[&a.key].bytes, d.images[&b.key].bytes, "the same placeholder"); +} + +/// Two files with the same pixels are linked under keys of their own: the images of one file share +/// a key, and those of the other file have a different one. +#[test] +fn files_with_the_same_pixels_have_keys_of_their_own() { + let dir = folder("same-pixels"); + let bytes = png(40, 20); + std::fs::write(dir.join("img").join("a.png"), &bytes).unwrap(); + std::fs::write(dir.join("img").join("b.png"), &bytes).unwrap(); + let body = r#""#; + let (d, w) = open_in(&dir, body); + assert!(w.is_empty(), "{w:?}"); + let [(_, a), (_, b), (_, a2)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + assert_ne!(a.key, b.key, "two files, two keys"); + assert_eq!(a.key, a2.key, "one file, one key"); + assert_eq!(*d.images[&a.key].bytes, bytes); + assert_eq!(*d.images[&b.key].bytes, bytes); +} + #[test] fn svg_images_import_as_vector_art() { let dir = folder("svgimage"); diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 226ad7832..9cc32eeee 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -83,6 +83,18 @@ impl FontDb { let _ = on; } + /// Is the lazy system-font fallback on ([`Self::set_system_fallback`])? + pub(crate) fn system_fallback_on(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + /// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the /// first time a lookup by family name needs them (or ahead of time by /// [`FontDb::scan_in_background`]). diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index adb3a62bf..e29334627 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -90,6 +90,15 @@ fn parse_list(source: &str) -> Exceptions { /// case-insensitive. Empty clears the list. pub fn set_hyphenation_exceptions(source: &str) { *store() = parse_list(source); + EXCEPTIONS_SET.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} + +/// How many times [`set_hyphenation_exceptions`] has run. +static EXCEPTIONS_SET: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// A number that changes whenever [`set_hyphenation_exceptions`] runs. +pub(crate) fn exceptions_generation() -> u64 { + EXCEPTIONS_SET.load(std::sync::atomic::Ordering::Relaxed) } /// The last string passed to [`set_hyphenation_exceptions`] (empty when none). @@ -492,12 +501,17 @@ mod tests { assert_eq!(hyphen_points_with("language", &opts(&|o| o.capitalized = false)), vec![3]); } - /// Process-wide store used by `prefs.set` / restore. + /// Process-wide store used by `prefs.set` / restore. Each change makes envelopes measure type + /// again. #[test] fn set_hyphenation_exceptions_round_trips_the_source_string() { + let inputs = crate::layout::envelope_ink_inputs(); set_hyphenation_exceptions("typography, hap-pen"); assert_eq!(hyphenation_exceptions(), "typography, hap-pen"); + let changed = crate::layout::envelope_ink_inputs(); + assert_ne!(changed, inputs, "the envelope inputs change with the exceptions"); set_hyphenation_exceptions(""); assert!(hyphenation_exceptions().is_empty()); + assert_ne!(crate::layout::envelope_ink_inputs(), changed, "and again when they are cleared"); } } diff --git a/crates/text/src/kept.rs b/crates/text/src/kept.rs new file mode 100644 index 000000000..801064252 --- /dev/null +++ b/crates/text/src/kept.rs @@ -0,0 +1,318 @@ +//! The layouts of a document that is opening, kept for its first frame: opening lays out every type +//! object for its bounds and baselines, and the first frame needs the same glyphs. A kept layout is +//! found by comparing the whole text object, and only while the fonts and the hyphenation exceptions +//! are the ones it was made with. Each is taken once, the frame that takes one drops the rest, a run of +//! [`KEPT_LAYOUTS_FRAMES`] frames that take none drops them all, and together they hold at most +//! [`KEPT_LAYOUTS_BUDGET`] bytes. + +use std::collections::{HashMap, VecDeque}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::mem::{size_of, size_of_val}; +use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError}; + +use vectorcraft_doc::color::Paint; +use vectorcraft_doc::{CharStyle, ParaStyle, TextKind, TextObject, TextRun}; +use vectorcraft_geom::{Anchor, PathData}; + +use crate::{FontDb, PositionedGlyph, TextLayout}; + +/// The most bytes of layouts kept at once; the oldest are dropped first. +const KEPT_LAYOUTS_BUDGET: usize = 128 << 20; + +/// How many frames may end with kept layouts on the shelf: the frames of other documents, or of a view +/// that shows no type, take none. +const KEPT_LAYOUTS_FRAMES: u32 = 8; + +/// The most layouts kept under one hash ([`text_hash`]): taking one compares it with those. +const KEPT_LAYOUTS_PER_HASH: usize = 32; + +/// What a layout reads besides its text object, in the global font database. Take it before laying +/// out: a layout that loads a font ends with another stamp, and is not kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LayoutStamp { + fonts: u64, + system_fallback: bool, + hyphenation: u64, +} + +impl LayoutStamp { + pub fn now() -> Self { + let db = FontDb::global(); + Self { fonts: db.generation(), system_fallback: db.system_fallback_on(), hyphenation: crate::hyphen::exceptions_generation() } + } +} + +struct Kept { + /// Tells this one from the others under its hash, in [`Shelf::order`]. + number: u64, + text: TextObject, + layout: TextLayout, + bytes: usize, +} + +#[derive(Default)] +struct Shelf { + stamp: Option, + kept: HashMap>, + /// Hash and number of each kept layout, the oldest first. Layouts already taken stay listed + /// until they reach the front. + order: VecDeque<(u64, u64)>, + next: u64, + bytes: usize, + taken: bool, + /// Frames that have ended since the last keep. + frames: u32, + /// Frames being drawn now, on any thread. + drawing: u32, +} + +impl Shelf { + fn clear(&mut self) { + self.kept.clear(); + self.order.clear(); + self.bytes = 0; + self.taken = false; + self.frames = 0; + } + + fn remove(&mut self, hash: u64, found: impl Fn(&Kept) -> bool) -> Option { + let list = self.kept.get_mut(&hash)?; + let kept = list.swap_remove(list.iter().position(found)?); + if list.is_empty() { + self.kept.remove(&hash); + } + self.bytes -= kept.bytes; + Some(kept) + } + + /// Keep `layout` of `text`, laid out at `stamp`, when that is still `now`. Layouts of an older + /// stamp are dropped. A layout whose memory is not counted ([`counts_its_memory`]) and one past + /// [`KEPT_LAYOUTS_PER_HASH`] are not kept. + fn keep(&mut self, text: &TextObject, stamp: LayoutStamp, now: LayoutStamp, layout: TextLayout, budget: usize) { + if stamp != now { + return; + } + if self.stamp != Some(now) { + self.clear(); + self.stamp = Some(now); + } + let bytes = layout_bytes(&layout) + text_bytes(text); + if bytes > budget || !counts_its_memory(text, &layout) { + return; + } + let (hash, number) = (text_hash(text), self.next); + if self.kept.get(&hash).is_some_and(|list| list.len() >= KEPT_LAYOUTS_PER_HASH) { + return; + } + while self.bytes + bytes > budget { + let Some((hash, number)) = self.order.pop_front() else { break }; + self.remove(hash, |k| k.number == number); + } + self.frames = 0; + self.next += 1; + self.bytes += bytes; + self.order.push_back((hash, number)); + self.kept.entry(hash).or_default().push(Kept { number, text: text.clone(), layout, bytes }); + } + + /// The layout kept for a text object equal to `text`, no longer kept after this. + fn take(&mut self, text: &TextObject, now: LayoutStamp) -> Option { + if self.kept.is_empty() { + return None; + } + if self.stamp != Some(now) { + self.clear(); + return None; + } + let kept = self.remove(text_hash(text), |k| k.text == *text)?; + if self.kept.is_empty() { + self.order.clear(); + } + self.taken = true; + Some(kept.layout) + } + + fn frame_begin(&mut self) { + self.drawing += 1; + } + + /// A frame ended: when no other frame is being drawn and a frame took a layout, or + /// [`KEPT_LAYOUTS_FRAMES`] frames have ended since the last keep, the others are dropped. + fn frame_done(&mut self) { + self.drawing = self.drawing.saturating_sub(1); + self.frames = self.frames.saturating_add(1); + if self.drawing == 0 && (self.taken || self.frames >= KEPT_LAYOUTS_FRAMES) { + self.clear(); + } + } +} + +fn text_hash(t: &TextObject) -> u64 { + let mut h = DefaultHasher::new(); + t.runs.iter().for_each(|r| { + r.text.hash(&mut h); + r.style.font_family.hash(&mut h); + r.style.font_style.hash(&mut h); + r.style.size.to_bits().hash(&mut h); + }); + t.xf.as_coeffs().iter().for_each(|c| c.to_bits().hash(&mut h)); + h.finish() +} + +/// Does [`text_bytes`] and [`layout_bytes`] count everything `t` and `layout` hold? They do not count +/// a gradient's stops, or a glyph's bitmap or color art. +fn counts_its_memory(t: &TextObject, layout: &TextLayout) -> bool { + let gradient = |p: &Paint| matches!(p, Paint::Gradient(_)); + !t.runs.iter().any(|r| gradient(&r.style.fill) || gradient(&r.style.stroke)) + && !layout.glyphs.iter().chain(&layout.ruby).any(|g| g.image.is_some() || g.colour.is_some()) +} + +fn paint_bytes(p: &Paint) -> usize { + match p { + Paint::Solid { swatch: Some(name), .. } | Paint::Pattern { pattern: name, .. } => name.len(), + _ => 0, + } +} + +fn path_bytes(p: &PathData) -> usize { + size_of_val(p.subpaths.as_slice()) + p.anchor_count() * size_of::() +} + +fn style_bytes(s: &CharStyle) -> usize { + s.font_family.len() + + s.font_style.len() + + s.font_version.as_ref().map_or(0, String::len) + + s.style_name.as_ref().map_or(0, String::len) + + s.language.as_ref().map_or(0, String::len) + + size_of_val(s.features.as_slice()) + + s.features.iter().map(String::len).sum::() + + s.font_axes.keys().map(|k| size_of::<(String, f64)>() + k.len()).sum::() + + paint_bytes(&s.fill) + + paint_bytes(&s.stroke) + + s.stroke_dash.as_ref().map_or(0, |d| size_of_val(d.pattern.as_slice())) + + s.glyph_alternate.as_ref().map_or(0, |g| g.font.len() + g.version.len()) +} + +fn run_bytes(r: &TextRun) -> usize { + size_of::() + r.text.len() + style_bytes(&r.style) + r.inline.as_ref().map_or(0, |i| i.symbol.len()) +} + +fn para_bytes(p: &ParaStyle) -> usize { + size_of_val(p.tabs.as_slice()) + p.tabs.iter().map(|t| t.leader.len()).sum::() + p.style_name.as_ref().map_or(0, String::len) +} + +fn text_bytes(t: &TextObject) -> usize { + let kind = match &t.kind { + TextKind::Point => 0, + TextKind::Area { frame } => path_bytes(frame), + TextKind::OnPath { path, .. } => path_bytes(path), + }; + size_of::() + + kind + + t.runs.iter().map(run_bytes).sum::() + + t.ruby.iter().map(|r| size_of_val(r) + r.text.len()).sum::() + + para_bytes(&t.para) + + t.paras.iter().map(|p| size_of_val(p) + para_bytes(p)).sum::() + + size_of_val(t.cached_baselines.as_slice()) + + t.wrap.iter().map(|w| size_of_val(w) + path_bytes(&w.path)).sum::() +} + +fn glyph_bytes(glyphs: &[PositionedGlyph]) -> usize { + size_of_val(glyphs) + glyphs.iter().map(|g| size_of_val(g.outline.elements()) + size_of_val(g.carets.as_slice())).sum::() +} + +fn layout_bytes(l: &TextLayout) -> usize { + size_of::() + + glyph_bytes(&l.glyphs) + + glyph_bytes(&l.ruby) + + size_of_val(l.lines.as_slice()) + + size_of_val(l.frames.as_slice()) + + size_of_val(l.inlines.as_slice()) + + l.markers.iter().map(|m| size_of_val(m) + m.text.len()).sum::() +} + +fn shelf() -> MutexGuard<'static, Shelf> { + static SHELF: LazyLock> = LazyLock::new(Mutex::default); + SHELF.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Keep `layout` of `t`, made from the global font database after `stamp` was taken, for the first +/// frame ([`take_kept_layout`]). `t` is the object as the document holds it. +pub fn keep_layout(t: &TextObject, stamp: LayoutStamp, layout: TextLayout) { + let now = LayoutStamp::now(); + shelf().keep(t, stamp, now, layout, KEPT_LAYOUTS_BUDGET); +} + +/// The layout [`keep_layout`] kept for a text object equal to `t`, when a frame is being drawn +/// ([`KeptLayoutsFrame`]) and the fonts and the hyphenation exceptions have not changed since. Taking +/// it removes it. A thumbnail, which draws outside a frame, leaves the layouts for the first frame. +pub fn take_kept_layout(t: &TextObject) -> Option { + let now = LayoutStamp::now(); + let mut shelf = shelf(); + if shelf.drawing == 0 { + return None; + } + shelf.take(t, now) +} + +/// A frame being drawn. When the last frame being drawn ends, the kept layouts are dropped if a frame +/// took one, or after a run of frames that took none. +pub struct KeptLayoutsFrame(()); + +impl KeptLayoutsFrame { + #[must_use] + pub fn begin() -> Self { + shelf().frame_begin(); + Self(()) + } +} + +impl Drop for KeptLayoutsFrame { + fn drop(&mut self) { + shelf().frame_done(); + } +} + +/// Drop every kept layout (tests in other crates, to lay out again what a frame would have taken). +#[cfg(any(test, feature = "test-fonts"))] +pub(crate) fn drop_kept_layouts() { + shelf().clear(); +} + +#[cfg(test)] +pub(crate) mod test_shelf { + use super::*; + + /// A shelf of its own, so that tests do not share the process's or its font database's stamp. + #[derive(Default)] + pub(crate) struct TestShelf(Shelf); + + impl TestShelf { + pub(crate) fn keep(&mut self, t: &TextObject, stamp: LayoutStamp, now: LayoutStamp, layout: TextLayout, budget: usize) { + self.0.keep(t, stamp, now, layout, budget); + } + pub(crate) fn take(&mut self, t: &TextObject, now: LayoutStamp) -> Option { + self.0.take(t, now) + } + pub(crate) fn frame_begin(&mut self) { + self.0.frame_begin(); + } + pub(crate) fn frame_done(&mut self) { + self.0.frame_done(); + } + pub(crate) fn bytes(&self) -> usize { + self.0.bytes + } + } + + pub(crate) const FRAMES: u32 = KEPT_LAYOUTS_FRAMES; + pub(crate) const PER_HASH: usize = KEPT_LAYOUTS_PER_HASH; + + pub(crate) fn stamp(fonts: u64, system_fallback: bool, hyphenation: u64) -> LayoutStamp { + LayoutStamp { fonts, system_fallback, hyphenation } + } + + pub(crate) fn bytes_of(t: &TextObject, layout: &TextLayout) -> usize { + layout_bytes(layout) + text_bytes(t) + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c36514eef..1ff72bdd2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1,6 +1,7 @@ //! Line breaking and glyph placement for point, area and on-path type. use std::ops::Range; +use std::sync::Arc; use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; @@ -13,13 +14,13 @@ use vectorcraft_geom::{ArcPath, PathData}; use crate::GlyphBend; use crate::composer::{Breakpoint, Params, compose}; -use crate::fontdb::FontDb; +use crate::fontdb::{FontDb, FontFace}; use crate::hyphen::points_for; use crate::justify::{Flex, LineFit, apply_desired, fit_line, is_word_space}; use crate::kashida; use crate::shape::{ - Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start, - style_metrics, + Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, run_face, shape_range, shape_runs, + soft_line_start, style_metrics, }; use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; @@ -36,9 +37,15 @@ pub(crate) use bidi::{is_rtl, para_bidi}; use breaking::tab_advance; #[cfg(test)] use breaking::{break_line, candidates}; +#[cfg(test)] +pub(crate) use driver::layout_faces_per_call; pub use driver::{decorations, fit_first_line, layout, layout_with, scaled}; use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; +#[cfg(test)] +pub(crate) use driver::envelope_ink_inputs; +pub use driver::{envelope_ink, register_envelope_ink}; + const EPS: f64 = 1e-6; /// Is glyph `g` a later glyph of the cluster of glyph `prev` before it (a combining mark the shaper @@ -108,6 +115,8 @@ struct Ctx<'a> { runs: Vec<(Range, &'a CharStyle)>, /// The inline graphic of each run that is one (parallel to `runs`). inlines: Vec>, + /// The face of each run (parallel to `runs`), looked up once ([`run_face`]). + faces: Vec>>>, default: CharStyle, opts: &'a LayoutOptions, /// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned @@ -126,13 +135,26 @@ struct Ctx<'a> { } impl Ctx<'_> { - fn style_at(&self, b: usize) -> &CharStyle { + /// The index of the run whose style [`Self::style_at`] gives for byte `b`; `None` without runs. + fn run_at(&self, b: usize) -> Option { // The runs follow one another: the first that ends after `b` is the only one that can hold it. let find = |b: usize| { let i = self.runs.partition_point(|(r, _)| r.end <= b); - self.runs.get(i).filter(|(r, _)| r.start <= b).map(|(_, s)| *s) + self.runs.get(i).filter(|(r, _)| r.start <= b).map(|_| i) }; - find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default) + find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| (!self.runs.is_empty()).then_some(0)) + } + + fn style_at(&self, b: usize) -> &CharStyle { + self.run_at(b).and_then(|i| self.runs.get(i)).map_or(&self.default, |(_, s)| *s) + } + + /// The face of [`Self::style_at`] `b` ([`FontDb::style_face`]). + fn face_at(&self, b: usize) -> Option> { + match self.run_at(b).and_then(|i| self.runs.get(i).map(|(_, s)| (i, *s))) { + Some((i, st)) => run_face(self.db, &self.faces, i, st), + None => self.db.style_face(&self.default), + } } /// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges @@ -158,7 +180,7 @@ impl Ctx<'_> { fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); let levels = bidi.map_or(&[][..], |b| &b.levels); - shape_range(self.db, self.text, r, &self.runs, &self.inlines, &self.opts.features, levels, &mut v); + shape_runs(self.db, self.text, r, &self.runs, &self.inlines, &self.faces, &self.opts.features, levels, &mut v); self.finish_shaping(&mut v, |g| self.style_at(g.byte)); v } diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 01fef1765..f55750e95 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -112,6 +112,62 @@ pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usi out } +/// The box of what `t` draws with text space mapped by `m`: its glyph outlines +/// ([`TextLayout::ink_bounds_in`]) and its underline and strikethrough bars ([`decorations`]). With +/// `projection` `(h, after)`, each point `m` maps is then drawn through `h` (a point `h` can't map +/// stays where it is, as the renderer draws it) and mapped by `after`; the outlines and bars are +/// flattened to within 1/1000 of their size, and images, color glyphs and inline graphics count by +/// their boxes' corners. What an envelope maps type from ([`register_envelope_ink`]). `None` when +/// nothing draws. +pub fn envelope_ink(t: &TextObject, m: Affine, projection: Option<(vectorcraft_geom::Homography, Affine)>) -> Option { + let db = FontDb::global(); + let laid = layout(db, t); + let bars = decorations(&laid, db, t).into_iter().map(|(_, bar)| bar); + let union = |acc: Option, b: Rect| Some(acc.map_or(b, |a| a.union(b))); + let Some((h, after)) = projection else { + return bars.map(|bar| (m * bar).bounding_box()).fold(laid.ink_bounds_in(m), union); + }; + let draw = |p: Point| { + let q = m * p; + after * h.apply(q).unwrap_or(q) + }; + let add = |acc: Option, p: Point| union(acc, Rect::from_points(p, p)); + let projected = |path: &BezPath| { + let size = path.bounding_box(); + let tolerance = (size.width().max(size.height()) / 1000.0).max(1e-6); + let mut points = vec![]; + kurbo::flatten(path, tolerance, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + points.push(draw(p)); + } + }); + points.into_iter().fold(None, add) + }; + let corners = |r: Rect| { + [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(draw).into_iter().fold(None, add) + }; + let glyphs = laid.glyphs.iter().chain(&laid.ruby).filter_map(|g| projected(&g.outline)); + let boxes = laid.glyphs.iter().flat_map(|g| { + let image = g.placed_image().map(|(image, xf)| xf.transform_rect_bbox(image.rect())); + let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| g.xf.transform_rect_bbox(b)); + [image, colour] + }); + let boxes = boxes.chain(laid.inlines.iter().map(|i| Some(i.bounds))).flatten().filter_map(corners); + glyphs.chain(bars.filter_map(|bar| projected(&bar))).chain(boxes).fold(None, union) +} + +/// Make envelopes map type from [`envelope_ink`], measured again when the fonts or the hyphenation +/// exceptions change (the first registration stays, see `vectorcraft_doc::live::set_type_ink`). +pub fn register_envelope_ink() { + vectorcraft_doc::live::set_type_ink(envelope_ink, envelope_ink_inputs); +} + +/// A number that changes whenever what [`envelope_ink`] reads besides the type changes: the +/// installed fonts and the hyphenation exceptions. +pub(crate) fn envelope_ink_inputs() -> u64 { + FontDb::global().generation().rotate_left(32) ^ crate::hyphen::exceptions_generation() +} + /// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass). const SHRINK_PASSES: usize = 11; @@ -119,6 +175,8 @@ const SHRINK_PASSES: usize = 11; /// fit, composer, OpenType features). Each run is set at the em size its font height reference /// gives it ([`FontDb::font_heights_resolved`]). pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { + #[cfg(any(test, feature = "test-fonts"))] + crate::test_fonts::count_layout(); let resolved = db.font_heights_resolved(t); let t = &*resolved; let first = layout_once(db, t, opts); @@ -202,6 +260,22 @@ pub fn scaled(t: &TextObject, f: f64) -> TextObject { s } +#[cfg(test)] +thread_local! { + /// Set while [`layout_faces_per_call`] lays text out: layout keeps no run faces. + static FACES_PER_CALL: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// [`layout`] keeping no run faces, so that every lookup of a face asks [`FontDb::style_face`] +/// again: the reference the faces kept per run are compared with. +#[cfg(test)] +pub(crate) fn layout_faces_per_call(db: &FontDb, t: &TextObject) -> TextLayout { + FACES_PER_CALL.set(true); + let l = layout(db, t); + FACES_PER_CALL.set(false); + l +} + /// One layout pass, without fitting. fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { let text = t.plain_text(); @@ -232,10 +306,14 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout } else { opts }; + let faces = runs.iter().map(|_| std::cell::OnceCell::new()).collect(); + #[cfg(test)] + let faces = if FACES_PER_CALL.get() { Vec::new() } else { faces }; let mut cx = Ctx { on_path: is_on_path, db, text: &text, + faces, runs, inlines, default: CharStyle::default(), @@ -254,7 +332,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout TextKind::OnPath { .. } => Affine::IDENTITY, TextKind::Point => { let first = cx.style_at(0); - let centre = db.style_face(first).map_or(EM_CENTER, |f| f.ideographic_centre()); + let centre = cx.face_at(0).map_or(EM_CENTER, |f| f.ideographic_centre()); Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN } _ => QUARTER_TURN, diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 112dabb23..79cc79ba6 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -232,11 +232,12 @@ fn prepare_paragraph<'a, 'text>( } } let pm = { - let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); + let face = cx.face_at(pr.start); + let (asc, desc, _) = style_metrics(face.as_deref(), cx.style_at(pr.start)); let lead = auto_leading(cx.style_at(pr.start)); let style = cx.style_at(pr.start); - let (cap, xh) = cap_x_heights(cx.db, style); - let centre = cx.db.style_face(style).map_or(EM_CENTER, |face| face.ideographic_centre()); + let (cap, xh) = cap_x_heights(face.as_deref(), style); + let centre = face.map_or(EM_CENTER, |face| face.ideographic_centre()); Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * style.size * style.v_scale / 100.0 } }; if pi > 0 { diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index ddf4e56f3..14bc3ef4b 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -170,7 +170,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, // A path out of range holds no text, as a path with no length holds none. let ap = if in_range(&path.to_bezpath()) { ArcPath::new(path) } else { ArcPath::default() }; let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| { - let s = style_metrics(cx.db, cx.style_at(0)); + let s = style_metrics(cx.face_at(0).as_deref(), cx.style_at(0)); (s.0, s.1) }); if ap.is_empty() { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 413ea72d7..aec815641 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -115,6 +115,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() { text: "A", runs: vec![(0..1, &style)], inlines: vec![None], + faces: vec![std::cell::OnceCell::new()], default: style.clone(), opts: &opts, vertical: false, diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9763ea4b6..d7bc1d444 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -22,6 +22,7 @@ mod fontdb; pub mod hyphen; mod justify; mod kashida; +mod kept; mod layout; mod shape; #[cfg(not(target_arch = "wasm32"))] @@ -40,9 +41,11 @@ pub use fontdb::{ style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; +pub use layout::{envelope_ink, register_envelope_ink}; use std::sync::Arc; 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, fit_first_line, layout, layout_with, scaled}; pub use vectorcraft_doc::TextObject; @@ -438,6 +441,20 @@ impl TextLayout { let inlines = self.inlines.iter().map(|i| Some(i.bounds)); glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b)) } + /// The box of [`Self::ink_bounds`]' ink with text space mapped by `m`: the glyph and ruby + /// outlines are mapped point by point, so a rotated or sheared `m` gives the outlines' own + /// bounds. Images, color glyphs and inline graphics count by their mapped boxes. + pub fn ink_bounds_in(&self, m: Affine) -> Option { + let outlined = |g: &PositionedGlyph| (!g.outline.elements().is_empty()).then(|| (m * &g.outline).bounding_box()); + let glyphs = self.glyphs.iter().flat_map(|g| { + let image = g.placed_image().map(|(image, xf)| (m * xf).transform_rect_bbox(image.rect())); + let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| (m * g.xf).transform_rect_bbox(b)); + [outlined(g), image, colour] + }); + let ruby = self.ruby.iter().map(outlined); + let inlines = self.inlines.iter().map(|i| Some(m.transform_rect_bbox(i.bounds))); + glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b)) + } /// All glyph outlines combined (e.g. for Create Outlines). pub fn to_bezpath(&self) -> BezPath { let mut p = BezPath::new(); @@ -832,6 +849,16 @@ pub fn logical_order(visual: &str) -> Option<(Vec, bool)> { Some((unicode_bidi::BidiInfo::reorder_visual(&levels), para.level.is_rtl())) } +/// Line metrics of the font at `index` of `data`, at its default axis settings, in font units: +/// (units per em, ascent, descent (negative below the baseline), line gap). egui builds its text +/// rows from these values. `None` if the font can't be read. +pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> { + use skrifa::MetadataProvider; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading)) +} + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 1a6716c5f..88f9a1f39 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -324,20 +324,33 @@ pub(crate) fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) } -/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading). -pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) { +/// The faces of a text's runs ([`FontDb::style_face`] of each run's style), each looked up the first +/// time layout asks for it and kept for the rest of that layout. +pub(crate) type RunFaces = [std::cell::OnceCell>>]; + +/// The face of run `ri`, whose style is `st`: kept in `faces` when it has a place for the run, +/// otherwise looked up. +pub(crate) fn run_face(db: &FontDb, faces: &RunFaces, ri: usize, st: &CharStyle) -> Option> { + match faces.get(ri) { + Some(face) => face.get_or_init(|| db.style_face(st)).clone(), + None => db.style_face(st), + } +} + +/// Vertical metrics (points) of style `st` in its resolved face `face`: (ascent, descent, leading). +pub(crate) fn style_metrics(face: Option<&FontFace>, st: &CharStyle) -> (f64, f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.style_face(st) else { + let Some(face) = face else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()); }; let k = st.size / face.upem; (face.ascent * k * vs, face.descent * k * vs, st.effective_leading()) } -/// Cap height and x height (points) of a style's resolved face. -pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) { +/// Cap height and x height (points) of style `st` in its resolved face `face`. +pub(crate) fn cap_x_heights(face: Option<&FontFace>, st: &CharStyle) -> (f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.style_face(st) else { + let Some(face) = face else { return (st.size * 0.7 * vs, st.size * 0.5 * vs); }; let k = st.size / face.upem * vs; @@ -358,6 +371,23 @@ pub(crate) fn shape_range( feats: &OtFeatures, levels: &[Level], out: &mut Vec, +) { + shape_runs(db, text, range, runs, inlines, &[], feats, levels, out); +} + +/// [`shape_range`] with `faces`, parallel to `runs`, holding the face of each run +/// ([`run_face`]). +#[allow(clippy::too_many_arguments)] +pub(crate) fn shape_runs( + db: &FontDb, + text: &str, + range: Range, + runs: &[(Range, &CharStyle)], + inlines: &[Option<&InlineArt>], + faces: &RunFaces, + feats: &OtFeatures, + levels: &[Level], + out: &mut Vec, ) { if text.get(range.clone()).is_none() || range.is_empty() { return; @@ -409,12 +439,13 @@ pub(crate) fn shape_range( let a = bytes.start; let b = bytes.end; if let Some(art) = art { - if let Some(face) = db.style_face(st).or_else(|| db.face_covering('a')) { + if let Some(face) = run_face(db, faces, source_runs.start, st).or_else(|| db.face_covering('a')) { out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a), feats.vertical)); } continue; } - let Some(primary) = db.style_face(st) else { continue }; + // A span's style is the style of its first source run. + let Some(primary) = run_face(db, faces, source_runs.start, st) else { continue }; let pmap = primary.skrifa().map(|f| f.charmap()); // Synthesized Small Caps shape lowercase letters separately (as smaller capitals). let small_caps = st.small_caps.is_some() && !st.all_caps; diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 69ccbfe70..2f591b6e0 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -26,6 +26,25 @@ pub(crate) fn keeps_shape_plans() -> bool { KEEP_SHAPE_PLANS.load(Ordering::SeqCst) } +std::thread_local! { + /// How many layouts this thread has started ([`layouts_on_this_thread`]). + static LAYOUTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(crate) fn count_layout() { + LAYOUTS.with(|n| n.set(n.get() + 1)); +} + +/// How many layouts this thread has started. +pub fn layouts_on_this_thread() -> u64 { + LAYOUTS.with(std::cell::Cell::get) +} + +/// Drop every layout kept for a first frame, so that a test can draw what laying out again draws. +pub fn drop_kept_layouts() { + crate::kept::drop_kept_layouts(); +} + /// The family of [`variable_font`]. pub const VARIABLE_FAMILY: &str = "Varitest Sans"; @@ -988,6 +1007,34 @@ fn remapped_source_sans(family: &str, postscript: &str, mapping: &[(char, char)] Some(builder.build()) } +/// The family of [`tall_ideograph_font`]. +pub const TALL_IDEOGRAPH_FAMILY: &str = "Tall Ideograph Sans"; + +/// A face that maps the ideograph 编 to the bundled Source Sans 3 `H` outline, which stands on the +/// baseline, with a taller ascent (1,058 units per 1,000) and descent (262) than Source Sans 3 in +/// its `hhea` and `OS/2` tables and no line gap: the vertical proportions of a CJK system face. +pub fn tall_ideograph_font() -> Option> { + const ASCENT: i16 = 1058; + const DESCENT: i16 = 262; + let mut data = remapped_source_sans(TALL_IDEOGRAPH_FAMILY, "TallIdeographSans-Regular", &[('编', 'H')])?; + let tables: Vec<(Tag, usize)> = { + let font = skrifa::FontRef::new(&data).ok()?; + font.table_directory.table_records().iter().map(|r| (Tag::new(&r.tag().to_be_bytes()), r.offset() as usize)).collect() + }; + let (ascent, descent, gap) = (ASCENT.to_be_bytes(), (-DESCENT).to_be_bytes(), [0, 0]); + for (tag, at) in tables { + if tag == Tag::new(b"hhea") { + // ascender, descender and lineGap at bytes 4 to 10. + data.get_mut(at + 4..at + 10)?.copy_from_slice(&[ascent, descent, gap].concat()); + } else if tag == Tag::new(b"OS/2") { + // sTypoAscender, sTypoDescender, sTypoLineGap, usWinAscent and usWinDescent at bytes 68 to 78. + let win = [ASCENT.unsigned_abs().to_be_bytes(), DESCENT.unsigned_abs().to_be_bytes()]; + data.get_mut(at + 68..at + 78)?.copy_from_slice(&[ascent, descent, gap, win[0], win[1]].concat()); + } + } + Some(data) +} + /// The family of [`soft_hyphen_outline_font`]. pub const SOFT_HYPHEN_OUTLINE_FAMILY: &str = "Soft Hyphen Profile Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 031d35bdc..6183278ed 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -16,6 +16,7 @@ mod hang; mod inline; mod joiners; mod justify; +mod kept_layouts; mod ligature_carets; mod path_joins; mod ruby; @@ -557,6 +558,100 @@ fn multiple_runs_and_styles() { assert_ne!(l.glyphs[0].font_id, l.glyphs[3].font_id); } +/// Each run's glyphs are set in its own style's face, and each empty paragraph's line takes the +/// ascent and descent of its own style's face: families mixed in a paragraph, a missing family that +/// falls back, a style the family does not have, and a change of family in the middle of a +/// paragraph. +#[test] +fn each_run_and_paragraph_takes_its_own_face() { + let styles = [ + serif(30.0), + CharStyle { font_family: "No Such Family".into(), font_style: "Bold".into(), ..style(10.0) }, + CharStyle { font_family: "Inter".into(), font_style: "Bold Italic".into(), ..style(20.0) }, + CharStyle { font_family: "JetBrains Mono".into(), ..style(14.0) }, + style(16.0), + ]; + let run = |text: &str, i: usize| TextRun::new(text, styles[i].clone()); + let mut t = point("", style(10.0)); + t.runs = vec![run("Ab", 0), run("cd\n", 1), run("\n", 2), run("\n", 3), run("xy", 1), run("zw", 4), run("Qr", 2)]; + let l = layout(db(), &t); + let faces: Vec<_> = styles.iter().map(|st| db().style_face(st).unwrap()).collect(); + let face_of_run = [0, 1, 2, 3, 1, 4, 2]; + let text = t.plain_text(); + let drawn: Vec<_> = l.glyphs.iter().filter(|g| text.get(g.byte..).is_some_and(|s| s.starts_with(char::is_alphabetic))).collect(); + assert_eq!(drawn.len(), 10); + for g in drawn { + assert_eq!(g.font_id, faces[face_of_run[g.run]].id(), "run {}", g.run); + } + assert_eq!(l.lines.len(), 4); + for (line, i) in [(1, 2), (2, 3)] { + let (face, size) = (&faces[i], styles[i].size); + let (ascent, descent) = (face.ascent * size / face.upem, face.descent * size / face.upem); + assert!((l.lines[line].ascent - ascent).abs() < 1e-9, "line {line}: {} versus {ascent}", l.lines[line].ascent); + assert!((l.lines[line].descent - descent).abs() < 1e-9, "line {line}: {} versus {descent}", l.lines[line].descent); + } + assert_ne!(faces[2].id(), faces[3].id()); +} + +/// Faces kept per run lay text out exactly as looking each face up again at every use does +/// ([`crate::layout::layout_faces_per_call`]): the same glyphs, positions, bounds and baselines, +/// with mixed families, a missing family that falls back, Bold and Italic asked of families +/// without those styles (their closest style stands in), and families that change in the middle of +/// a paragraph, in point, vertical, area and path type. +#[test] +fn faces_kept_per_run_lay_out_as_faces_looked_up_at_each_use() { + let styles = [ + serif(24.0), + CharStyle { font_family: "No Such Family".into(), ..style(12.0) }, + CharStyle { font_family: "Source Serif 4".into(), font_style: "Bold".into(), ..style(18.0) }, + CharStyle { font_family: "Inter".into(), font_style: "Italic".into(), ..style(20.0) }, + CharStyle { font_family: "JetBrains Mono".into(), font_style: "Bold Italic".into(), ..style(14.0) }, + style(16.0), + ]; + let matched = [(1, FontMatch::Missing), (2, FontMatch::Style), (3, FontMatch::Style), (4, FontMatch::Style)]; + for (i, m) in matched { + let st = &styles[i]; + assert_eq!(db().resolve(&st.font_family, &st.font_style).map(|(_, m)| m), Some(m), "{} {}", st.font_family, st.font_style); + } + let style_of_run = [0, 1, 2, 3, 4, 2, 4, 5, 3, 1]; + let texts = ["Ab ", "cd ef\n", "\n", "gh ", "ij", " kl\n", "\n", "mn ", "op ", "qr"]; + let runs: Vec<_> = texts.into_iter().zip(style_of_run).map(|(text, i)| TextRun::new(text, styles[i].clone())).collect(); + let mut horizontal = point("", style(10.0)); + horizontal.runs = runs; + let mut vertical = horizontal.clone(); + vertical.vertical = true; + let mut wrapped = area("", style(10.0), Rect::new(0.0, 0.0, 60.0, 400.0), Justify::Left); + wrapped.runs = horizontal.runs.clone(); + let mut on_path = horizontal.clone(); + let path = kurbo::Line::new((0.0, 0.0), (400.0, 60.0)).to_path(0.1); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&path), start: 0.0, end: None }; + let mut empty_path = on_path.clone(); + empty_path.runs = vec![TextRun::new("", styles[1].clone())]; + let glyph = |g: &PositionedGlyph| { + let b = g.outline.bounding_box(); + (g.font_id, g.gid, g.run, g.byte, g.line, [g.origin.x, g.origin.y, g.advance, g.width, b.x0, b.y0, b.x1, b.y1], g.xf.as_coeffs()) + }; + let line = |l: &LineInfo| [l.baseline, l.first_baseline, l.ascent, l.descent, l.x0, l.x1]; + for (name, t) in [("point", &horizontal), ("vertical", &vertical), ("area", &wrapped), ("path", &on_path), ("empty path", &empty_path)] { + let kept = layout(db(), t); + let looked_up = crate::layout::layout_faces_per_call(db(), t); + assert_eq!(kept.glyphs.iter().map(glyph).collect::>(), looked_up.glyphs.iter().map(glyph).collect::>(), "{name}: glyphs"); + assert_eq!(kept.lines.iter().map(line).collect::>(), looked_up.lines.iter().map(line).collect::>(), "{name}: lines"); + assert_eq!(kept.bounds, looked_up.bounds, "{name}: bounds"); + assert_eq!(kept.line_xf, looked_up.line_xf, "{name}: line transform"); + assert_eq!(kept.overflow, looked_up.overflow, "{name}: overflow"); + } + // The stand-in faces set the glyphs of the runs that ask for a missing family or style. + let l = layout(db(), &horizontal); + let text = horizontal.plain_text(); + let drawn: Vec<_> = l.glyphs.iter().filter(|g| text.get(g.byte..).is_some_and(|s| s.starts_with(char::is_alphabetic))).collect(); + assert_eq!(drawn.len(), 18); + for g in drawn { + assert_eq!(Some(g.font_id), db().style_face(&styles[style_of_run[g.run]]).map(|f| f.id()), "run {}", g.run); + } + assert_eq!(l.lines.len(), 5); +} + #[test] fn many_paragraphs_of_their_own_runs_lay_out_in_linear_time() { // 20,000 one-letter paragraphs, each a run of its own: every paragraph looking through every diff --git a/crates/text/src/tests/kept_layouts.rs b/crates/text/src/tests/kept_layouts.rs new file mode 100644 index 000000000..e452ab3cf --- /dev/null +++ b/crates/text/src/tests/kept_layouts.rs @@ -0,0 +1,316 @@ +//! Layouts kept for the first frame: found only for an equal text object at the same stamp, taken +//! once, and limited in bytes. + +use kurbo::{Affine, Point}; +use vectorcraft_doc::color::{Color, Gradient, GradientPaint, Paint}; +use vectorcraft_doc::{CharStyle, Dash, RubySpan, TabAlign, TabStop, TextKind, TextObject, TextWrap, WrapShape}; +use vectorcraft_geom::PathData; + +use crate::kept::test_shelf::{FRAMES, PER_HASH, TestShelf, bytes_of, stamp}; +use crate::{FontDb, ListMarker, TextLayout, layout}; + +const BUDGET: usize = 1 << 30; + +fn object(text: &str) -> TextObject { + TextObject::point(Point::ZERO, text, CharStyle::default()) +} + +fn laid_out(t: &TextObject) -> TextLayout { + layout(FontDb::global(), t) +} + +#[test] +fn a_kept_layout_is_taken_once() { + let at = stamp(1, true, 1); + let t = object("Type"); + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + let taken = shelf.take(&t.clone(), at).unwrap(); + assert_eq!(taken.glyphs.len(), 4); + assert!(shelf.take(&t, at).is_none()); + assert_eq!(shelf.bytes(), 0); +} + +#[test] +fn equal_objects_take_a_layout_each() { + let at = stamp(1, true, 1); + let t = object("Type"); + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + assert!(shelf.take(&t, at).is_some() && shelf.take(&t, at).is_some()); + assert!(shelf.take(&t, at).is_none()); +} + +#[test] +fn a_changed_object_does_not_find_the_layout() { + let at = stamp(1, true, 1); + let t = object("Type"); + fn frame(w: f64) -> TextKind { + TextKind::Area { frame: PathData::from_bezpath(&kurbo::Shape::to_path(&kurbo::Rect::new(0.0, 0.0, w, 50.0), 0.1)) } + } + let changes: [fn(&mut TextObject); 11] = [ + |t| t.runs[0].text = "Typo".into(), + |t| t.runs[0].style.size += 1.0, + |t| t.runs[0].style.font_family = "Inter".into(), + |t| t.runs[0].style.font_style = "Bold".into(), + |t| t.runs[0].style.language = Some("tr".into()), + |t| t.xf = Affine::translate((1.0, 0.0)), + |t| t.vertical = true, + |t| t.para.first_line_indent = 3.0, + |t| t.first_para_continues = true, + |t| t.area.columns = 2, + |t| t.kind = frame(100.0), + ]; + for (i, change) in changes.iter().enumerate() { + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + let mut other = t.clone(); + change(&mut other); + assert!(shelf.take(&other, at).is_none(), "change {i}"); + assert!(shelf.take(&t, at).is_some(), "change {i}: the object itself still finds it"); + } + // Two frame shapes of the same size, each a text object of its own. + let mut area = t.clone(); + area.kind = frame(100.0); + let mut wider = t.clone(); + wider.kind = frame(120.0); + let mut shelf = TestShelf::default(); + shelf.keep(&area, at, at, laid_out(&area), BUDGET); + assert!(shelf.take(&wider, at).is_none()); + assert!(shelf.take(&area, at).is_some()); +} + +#[test] +fn a_changed_stamp_drops_the_layouts() { + let t = object("Type"); + let at = stamp(1, true, 1); + for later in [stamp(2, true, 1), stamp(1, false, 1), stamp(1, true, 2)] { + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + assert!(shelf.take(&t, later).is_none(), "{later:?}"); + assert_eq!(shelf.bytes(), 0, "{later:?}"); + assert!(shelf.take(&t, at).is_none(), "{later:?}: gone for good"); + } +} + +#[test] +fn a_layout_made_while_the_stamp_changed_is_not_kept() { + let t = object("Type"); + let mut shelf = TestShelf::default(); + shelf.keep(&t, stamp(1, true, 1), stamp(2, true, 1), laid_out(&t), BUDGET); + assert_eq!(shelf.bytes(), 0); + assert!(shelf.take(&t, stamp(1, true, 1)).is_none() && shelf.take(&t, stamp(2, true, 1)).is_none()); +} + +#[test] +fn keeping_at_a_new_stamp_lets_the_older_layouts_go() { + let (old, new) = (stamp(1, true, 1), stamp(2, true, 1)); + let (a, b) = (object("Old"), object("New")); + let mut shelf = TestShelf::default(); + shelf.keep(&a, old, old, laid_out(&a), BUDGET); + shelf.keep(&b, new, new, laid_out(&b), BUDGET); + assert!(shelf.take(&a, new).is_none()); + assert!(shelf.take(&b, new).is_some()); +} + +#[test] +fn the_budget_lets_the_oldest_layouts_go() { + let at = stamp(1, true, 1); + let (a, b, c) = (object("Abc"), object("Def"), object("Ghi")); + let (la, lb, lc) = (laid_out(&a), laid_out(&b), laid_out(&c)); + let budget = bytes_of(&b, &lb) + bytes_of(&c, &lc); + assert!(bytes_of(&a, &la) + budget > budget); + let mut shelf = TestShelf::default(); + shelf.keep(&a, at, at, la, budget); + shelf.keep(&b, at, at, lb, budget); + shelf.keep(&c, at, at, lc, budget); + assert!(shelf.bytes() <= budget); + assert!(shelf.take(&a, at).is_none(), "the oldest went"); + assert!(shelf.take(&b, at).is_some() && shelf.take(&c, at).is_some()); + assert_eq!(shelf.bytes(), 0); +} + +#[test] +fn a_layout_larger_than_the_budget_is_not_kept_and_lets_nothing_go() { + let at = stamp(1, true, 1); + let (small, large) = (object("A"), object(&"Long text ".repeat(50))); + let (ls, ll) = (laid_out(&small), laid_out(&large)); + let budget = bytes_of(&small, &ls); + assert!(bytes_of(&large, &ll) > budget); + let mut shelf = TestShelf::default(); + shelf.keep(&small, at, at, ls, budget); + shelf.keep(&large, at, at, ll, budget); + assert!(shelf.take(&large, at).is_none()); + assert!(shelf.take(&small, at).is_some()); +} + +#[test] +fn a_frame_that_took_a_layout_lets_the_others_go() { + let at = stamp(1, true, 1); + let (a, b) = (object("Seen"), object("Out of view")); + let mut shelf = TestShelf::default(); + shelf.keep(&a, at, at, laid_out(&a), BUDGET); + shelf.keep(&b, at, at, laid_out(&b), BUDGET); + shelf.frame_done(); + assert!(shelf.bytes() > 0, "a frame that took none lets none go"); + assert!(shelf.take(&a, at).is_some()); + shelf.frame_done(); + assert_eq!(shelf.bytes(), 0); + assert!(shelf.take(&b, at).is_none()); +} + +#[test] +fn a_layout_with_color_art_is_not_kept() { + let at = stamp(1, true, 1); + let t = object("Type"); + let mut l = laid_out(&t); + l.glyphs[0].colour = Some(std::sync::Arc::new(vectorcraft_doc::Node::group(vectorcraft_doc::NodeId(1), vec![]))); + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, l, BUDGET); + assert_eq!(shelf.bytes(), 0); + assert!(shelf.take(&t, at).is_none()); +} + +#[test] +fn an_object_with_a_gradient_is_not_kept() { + let at = stamp(1, true, 1); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + for stroke in [false, true] { + let mut t = object("Type"); + if stroke { + t.runs[0].style.stroke = gradient.clone(); + } else { + t.runs[0].style.fill = gradient.clone(); + } + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + assert_eq!(shelf.bytes(), 0, "stroke: {stroke}"); + } +} + +/// Objects that differ only past the hash stay few enough to compare. +#[test] +fn one_hash_holds_a_limited_number_of_layouts() { + let at = stamp(1, true, 1); + let objects: Vec = (0..PER_HASH + 2) + .map(|i| { + let mut t = object("Type"); + t.para.first_line_indent = i as f64; + t + }) + .collect(); + let mut shelf = TestShelf::default(); + for t in &objects { + shelf.keep(t, at, at, laid_out(t), BUDGET); + } + let taken = objects.iter().filter(|t| shelf.take(t, at).is_some()).count(); + assert_eq!(taken, PER_HASH); + assert_eq!(shelf.bytes(), 0); +} + +#[test] +fn a_frame_still_being_drawn_keeps_the_layouts_it_may_take() { + let at = stamp(1, true, 1); + let (a, b) = (object("Canvas"), object("Other pane")); + let mut shelf = TestShelf::default(); + shelf.keep(&a, at, at, laid_out(&a), BUDGET); + shelf.keep(&b, at, at, laid_out(&b), BUDGET); + shelf.frame_begin(); + shelf.frame_begin(); + assert!(shelf.take(&a, at).is_some()); + shelf.frame_done(); + assert!(shelf.take(&b, at).is_some(), "the other frame takes its layout after the first ends"); + shelf.keep(&a, at, at, laid_out(&a), BUDGET); + shelf.frame_done(); + assert_eq!(shelf.bytes(), 0, "the last frame to end lets the rest go"); +} + +#[test] +fn frames_that_take_nothing_let_the_layouts_go() { + let at = stamp(1, true, 1); + let t = object("Out of view"); + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + for _ in 1..FRAMES { + shelf.frame_done(); + } + assert!(shelf.bytes() > 0, "the last frame has not ended"); + // Another open starts the count again. + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + shelf.frame_done(); + assert!(shelf.bytes() > 0, "a keep starts the count again"); + for _ in 1..FRAMES { + shelf.frame_done(); + } + assert_eq!(shelf.bytes(), 0); + assert!(shelf.take(&t, at).is_none()); +} + +#[test] +fn the_budget_counts_frames_paths_and_markers() { + let t = object("Type"); + let mut l = laid_out(&t); + let plain = bytes_of(&t, &l); + l.frames = vec![kurbo::Rect::ZERO; 1000]; + let with_frames = bytes_of(&t, &l); + l.markers = vec![ListMarker { glyphs: 0..0, tab: 0, text: "x".repeat(1000) }]; + let with_markers = bytes_of(&t, &l); + let mut area = t.clone(); + let wide = kurbo::BezPath::from_vec( + std::iter::once(kurbo::PathEl::MoveTo(kurbo::Point::ZERO)) + .chain((1..1000).map(|i| kurbo::PathEl::LineTo(kurbo::Point::new(f64::from(i), 0.0)))) + .collect(), + ); + area.kind = TextKind::Area { frame: PathData::from_bezpath(&wide) }; + let mut wrapped = t.clone(); + wrapped.wrap = vec![WrapShape { path: PathData::from_bezpath(&wide), wrap: TextWrap::default() }]; + let mut paragraphs = t.clone(); + paragraphs.paras = vec![paragraphs.para.clone(); 100]; + let mut styled = t.clone(); + styled.runs[0].style.font_family = "x".repeat(1000); + let mut featured = t.clone(); + featured.runs[0].style.features = vec!["x".repeat(1000)]; + let mut swatched = t.clone(); + swatched.runs[0].style.fill = Paint::Solid { color: Color::BLACK, swatch: Some("x".repeat(1000)), tint: 1.0 }; + let mut dashed = t.clone(); + dashed.runs[0].style.stroke_dash = Some(Dash { pattern: vec![1.0; 1000], ..Dash::default() }); + let mut tabbed = t.clone(); + tabbed.para.tabs = vec![TabStop { position: 10.0, align: TabAlign::default(), leader: "x".repeat(1000), align_on: '.' }]; + let mut with_ruby = t.clone(); + with_ruby.ruby = vec![RubySpan::new(0, 1, "x".repeat(1000))]; + let mut with_carets = laid_out(&t); + let no_carets = bytes_of(&t, &with_carets); + with_carets.glyphs[0].carets = vec![(0, 0.0); 1000]; + let anchors = 999 * size_of::(); + let counted = [ + ("frames", with_frames >= plain + 1000 * size_of::()), + ("markers", with_markers >= with_frames + 1000), + ("the frame's path", bytes_of(&area, &l) >= with_markers + anchors), + ("the wrap's path", bytes_of(&wrapped, &l) >= with_markers + anchors), + ("paragraphs", bytes_of(¶graphs, &l) >= with_markers + 100 * size_of::()), + ("a run's family", bytes_of(&styled, &l) >= with_markers + 900), + ("a run's features", bytes_of(&featured, &l) >= with_markers + 1000), + ("a swatch's name", bytes_of(&swatched, &l) >= with_markers + 1000), + ("a stroke's dashes", bytes_of(&dashed, &l) >= with_markers + 1000 * size_of::()), + ("a tab's leader", bytes_of(&tabbed, &l) >= with_markers + 1000), + ("ruby text", bytes_of(&with_ruby, &l) >= with_markers + 1000), + ("carets", bytes_of(&t, &with_carets) >= no_carets + 1000 * size_of::<(usize, f64)>()), + ]; + let missed: Vec<&str> = counted.iter().filter(|(_, ok)| !ok).map(|(name, _)| *name).collect(); + assert!(missed.is_empty(), "not counted: {missed:?}"); +} + +#[test] +fn a_taken_layout_stays_within_the_budget_after_many_keeps() { + // Taken layouts leave nothing behind that a later keep has to count. + let at = stamp(1, true, 1); + let t = object("Type"); + let budget = bytes_of(&t, &laid_out(&t)); + let mut shelf = TestShelf::default(); + for _ in 0..50 { + shelf.keep(&t, at, at, laid_out(&t), budget); + assert!(shelf.take(&t, at).is_some()); + assert_eq!(shelf.bytes(), 0); + } +} diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 8f37e5905..4791347d0 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -704,12 +704,12 @@ fn area_flow_moves_a_word_down_to_a_line_wide_enough_for_it() { } /// Lays `t` out as [`layout`] does, on a thread of its own, and fails the test when that panics or -/// takes more than 20 s. +/// has not ended after 300 s. fn layout_soon(t: &TextObject) -> TextLayout { let (tx, rx) = std::sync::mpsc::channel(); let (t, text) = (t.clone(), t.plain_text()); std::thread::spawn(move || tx.send(layout(db(), &t)).ok()); - rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|e| panic!("laying out {text:?}: {e}")) + rx.recv_timeout(std::time::Duration::from_secs(300)).unwrap_or_else(|e| panic!("laying out {text:?}: {e}")) } /// Each line's baseline, left and right edges, and byte range. diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 85b727222..835303b44 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -242,6 +242,9 @@ pub struct ToolContext<'a> { /// object shows how far the selection is from it, and over an empty part of an artboard how /// far it is from the artboard's edges ([`guides::distance_guides`]). pub distance_guides: bool, + /// Smart Guides → Snap to Last Location: a selection moved by dragging goes back to where the + /// move began while it is within Snapping Tolerance of it ([`guides::Targets::snap_back`]). + pub snap_to_last_location: bool, /// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the /// pointer, a dragged edge or a drawn point. pub snapping_tolerance: f64, diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 2b2f04c83..d27a9f2a4 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -60,7 +60,7 @@ use crate::guides::{HandleSnap, Leave, PointSnap, Targets}; use crate::meshedit::{MeshEdit, grid_of}; use crate::pathtype::{self, BracketDrag, near_bracket}; use crate::rulerguide::GuideEdit; -use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at}; +use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at, selection_bounds}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Debug)] @@ -200,6 +200,10 @@ pub struct DirectSelectionTool { /// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left /// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original. copy_snap: Option, + /// With Smart Guides › Snap to Last Location, for paths selected whole that an anchor holds + /// while they move: the targets that put the move back where it began, and the bounds it began + /// with. Set when the move begins. + last_location: Option<(Targets, Option)>, /// What the dragged handle snaps to. handle_snap: HandleSnap, /// What the objects being moved snap to, gathered when the move begins. @@ -249,6 +253,7 @@ impl DirectSelectionTool { anchor_snap: None, line_end: None, copy_snap: None, + last_location: None, handle_snap: HandleSnap::default(), move_snap: None, partial_snap: None, @@ -724,6 +729,7 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; self.guides.clear(); (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); + self.last_location = None; (self.click, self.click_segment, self.click_group, self.frame_click) = (None, None, None, None); if frame { vec![Action::Cancel, Action::Notify(FRAME_PICKED.into())] } else { vec![Action::Cancel] } } @@ -1440,6 +1446,9 @@ impl DirectSelectionTool { // Alt can go down or up at any time: gather both while nothing has moved. self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); + // As a move with the Selection tool does ([`MoveSnap::new`]). + let back = cx.smart_guides && !cx.snap_to_grid && cx.snap_to_last_location; + self.last_location = back.then(|| (Targets::last_location(cx), selection_bounds(cx))); } else { self.move_snap = Some(MoveSnap::new(cx)); } @@ -1463,7 +1472,10 @@ impl DirectSelectionTool { return out; } let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; - if let (Some(grab), Some(snap)) = (grab, snap) { + let back = self.last_location.as_ref().filter(|_| !ev.mods.alt).and_then(|(t, from)| t.snap_back(*from, d, cx.snap_tol())); + if let (Some(_), Some(ov)) = (grab, back) { + (d, self.guides) = (Vec2::ZERO, ov); + } else if let (Some(grab), Some(snap)) = (grab, snap) { let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); (d, self.guides) = (q - grab, guides); @@ -1487,6 +1499,7 @@ impl DirectSelectionTool { self.state = State::Idle; self.guides.clear(); (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); + self.last_location = None; let group = self.click_group.take(); // A click on one of several selected frame anchors selects it alone. if let (false, Some((id, anchor))) = (began, self.frame_click.take()) { diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index c249bd40a..a8507e693 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -542,6 +542,73 @@ fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() { assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); } +/// Smart Guides › Snap to Last Location: a path selected whole that Direct Selection moves held by +/// one of its anchors goes back to where it began within Snapping Tolerance, labeled "last +/// location", a move Shift keeps to its line too; farther out, or with the option off, it moves as +/// dragged. +#[test] +fn snap_to_last_location_puts_a_path_moved_by_its_anchor_back() { + let (mut d, id) = doc_with_rect(); + // An artboard whose edges and center are out of reach. + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let on = ToolContext { snap_to_last_location: true, snapping_tolerance: 10.0, ..cx(&d, &s, &p) }; + // A press on the top-left anchor, then a drag to `to` with `m` held. + let moved = |c: &ToolContext, to: (f64, f64), m: Mods| { + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); + let a = t.pointer(c, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m)); + let Some(Action::Preview(command, v)) = a.last() else { panic!("{a:?}") }; + assert_eq!(command, "object.transform"); + let labels: Vec = + t.overlays(c).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect(); + (Vec2::new(v["matrix"][4].as_f64().unwrap_or(f64::NAN), v["matrix"][5].as_f64().unwrap_or(f64::NAN)), labels) + }; + let none = Mods::default(); + let (back, labels) = moved(&on, (106.0, 105.0), none); + assert!(back.hypot() < 1e-9, "{back:?}: 7.8 pt away, within 10"); + assert_eq!(labels, ["last location"]); + let shift = Mods { shift: true, ..none }; + let (back, _) = moved(&on, (107.0, 100.0), shift); + assert!(back.hypot() < 1e-9, "{back:?}: a Shift move goes back too"); + let (out, labels) = moved(&on, (109.0, 108.0), none); + assert!((out - Vec2::new(9.0, 8.0)).hypot() < 1e-9, "{out:?}: 12 pt away, out of reach"); + assert!(labels.is_empty(), "{labels:?}"); + let off = ToolContext { snap_to_last_location: false, ..on }; + let (stays, _) = moved(&off, (106.0, 105.0), none); + assert!((stays - Vec2::new(6.0, 5.0)).hypot() < 1e-9, "{stays:?}: the option off"); +} + +/// View › Snap to Pixel wins over Smart Guides › Snap to Last Location for a path selected whole +/// that Direct Selection moves held by an anchor: a short move puts the held anchor, off the +/// pixel grid before, on whole pixels and shows no "last location" label. +#[test] +fn snap_to_pixel_wins_over_snap_to_last_location_for_a_path_moved_by_its_anchor() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.3, 100.4, 200.3, 200.4)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, square, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let c = ToolContext { snap_to_pixel: true, snap_to_last_location: true, snapping_tolerance: 10.0, ..cx(&d, &s, &p) }; + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.3, 100.4)).is_empty()); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 106.3, 105.4)); + let Some(Action::Preview(command, v)) = a.last() else { panic!("{a:?}") }; + assert_eq!(command, "object.transform"); + let m = Vec2::new(v["matrix"][4].as_f64().unwrap_or(f64::NAN), v["matrix"][5].as_f64().unwrap_or(f64::NAN)); + // The held anchor (106.3, 105.4) rounds to (106, 105). + assert!((m - Vec2::new(5.7, 4.6)).hypot() < 1e-9, "{m:?}"); + let labels: Vec = + t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect(); + assert!(labels.is_empty(), "{labels:?}"); +} + /// A click (no drag) on an anchor that is selected with others, whether the whole path is or /// some of its anchors, selects that anchor alone, as in Illustrator: a press keeps the /// selection so a drag moves it all, the release without a drag narrows it. Shift still diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 709e1f53d..17b24ec16 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -57,6 +57,9 @@ pub struct Targets { pub(super) perpendicular: bool, /// How the guides these targets produce look. pub(super) style: GuideStyle, + /// Smart Guides › Snap to Last Location: a move goes back to where it began when it comes near + /// ([`Self::snap_back`]). Set only for moving the selection itself ([`Self::for_move`]). + pub(super) last_location: bool, } /// How many anchors, and how many segments, the targets gather at most. @@ -163,19 +166,38 @@ impl Targets { /// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection, /// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides › - /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). + /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). With Smart + /// Guides › Snap to Last Location, a move (not a copy, which has nothing in `exclude`) goes back + /// to where it began when it comes near ([`Self::snap_back`]), unless Snap to Pixel is on. pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { let mut t = Self::collect(cx.doc, exclude, None).styled(cx); if cx.spacing_guides { t.boxes = object_boxes(cx.doc, &cx.selection.objects); } + t.last_location = cx.snap_to_last_location && !cx.snap_to_pixel && !exclude.is_empty(); t } + /// Smart Guides › Snap to Last Location: a move by `d` that is no farther than `tol` from + /// where it began goes back there. Returns the guides for it, a "last location" label at the + /// center of `from` (the moved bounds when the move began), or None when the move stays where + /// it is: the option is off, the targets are for a copy, or `d` is longer than `tol`. + pub fn snap_back(&self, from: Option, d: Vec2, tol: f64) -> Option> { + (self.last_location && d.hypot() <= tol).then(|| from.and_then(|r| self.label(r.center(), "last location")).into_iter().collect()) + } + + /// Targets that hold nothing but Smart Guides › Snap to Last Location ([`Self::snap_back`]), + /// styled as `cx` asks: for a move of the selection whose other snaps come from other targets, + /// such as paths selected whole that Direct Selection moves held by an anchor. Snap to Pixel + /// turns it off, as it does every Smart Guides snap. + pub fn last_location(cx: &ToolContext) -> Self { + Self { last_location: cx.snap_to_last_location && !cx.snap_to_pixel, ..Self::default() }.styled(cx) + } + /// Targets for scaling the selection: the other art, styled as `cx` asks, and with Smart Guides › /// Spacing Guides the neighbours whose gaps the moving edge should match ([`Self::snap_scale`]). pub fn for_scale(cx: &ToolContext) -> Self { - Self::for_move(cx, &cx.selection.objects) + Self { last_location: false, ..Self::for_move(cx, &cx.selection.objects) } } /// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c6883c3fd..f77ba9dbf 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -532,6 +532,7 @@ pub(crate) mod testutil { transform_tools_guides: true, spacing_guides: true, distance_guides: true, + snap_to_last_location: false, snapping_tolerance: 4.0, construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES), screen: None, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 0f879cb55..6d90e79e3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -231,9 +231,13 @@ impl MoveSnap { } /// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its - /// guides. + /// guides. With Smart Guides › Snap to Last Location, a move within Snapping Tolerance of where + /// it began goes back there before anything else snaps ([`Targets::snap_back`]). pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec) { let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) }; + if let Some(ov) = targets.as_ref().and_then(|t| t.snap_back(self.bounds, d, cx.snap_tol())) { + return (Vec2::ZERO, ov); + } let mut guides = vec![]; if cx.snap_to_grid && let Some(b) = self.bounds @@ -291,7 +295,8 @@ impl MoveSnap { /// whole pixel on either axis (the one axis of a level or upright line). The bounds line up /// with the other art ([`Targets::snap_rects_along`]) and the grabbed point lands where the line /// passes an anchor or crosses a ruler guide ([`Targets::snap_point_along`]). A move of zero - /// length does not snap. + /// length does not snap, and Snap to Last Location goes back to where the move began first, as + /// in [`Self::snap`]. pub(crate) fn snap_along(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec) { let len = d.hypot(); if !len.is_finite() || len <= 1e-9 { @@ -309,6 +314,9 @@ impl MoveSnap { .unwrap_or(0.0) }; let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) }; + if let Some(ov) = targets.as_ref().and_then(|t| t.snap_back(self.bounds, d, cx.snap_tol())) { + return (Vec2::ZERO, ov); + } let mut guides = vec![]; if cx.snap_to_grid && let Some(b) = self.bounds diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 94fb67786..242b9f928 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -782,6 +782,80 @@ fn an_alt_copy_snaps_to_the_original_left_behind() { assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0); } +/// Smart Guides › Snap to Last Location: a move that comes within Snapping Tolerance of where it +/// began goes back there, labeled "last location", a Shift move too; farther out, with the option +/// or Smart Guides off, it stays where it is dragged, and an Alt copy snaps as it does with the +/// option off. +#[test] +fn snap_to_last_location_puts_a_move_back_where_it_began() { + let (mut d, id) = doc_with_rect(); + // An artboard whose edges and center are out of reach. + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let on = ToolContext { snap_to_last_location: true, snapping_tolerance: 10.0, ..cx(&d, &s, &p) }; + let moved = |c: &ToolContext, to: (f64, f64), m: Mods| { + let mut t = SelectionTool::default(); + t.pointer(c, &ev(PointerKind::Down, 150.0, 150.0).with_mods(m)); + let a = t.pointer(c, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let labels: Vec = + t.overlays(c).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect(); + (Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), labels) + }; + let none = Mods::default(); + let (back, labels) = moved(&on, (156.0, 155.0), none); + assert!(moved_by(back, (0.0, 0.0)), "{back:?}: 7.8 pt away, within 10"); + assert_eq!(labels, ["last location"]); + let shift = Mods { shift: true, ..none }; + assert!(moved_by(moved(&on, (157.0, 150.0), shift).0, (0.0, 0.0)), "a Shift move goes back too"); + let (out, labels) = moved(&on, (159.0, 158.0), none); + assert!(moved_by(out, (9.0, 8.0)), "{out:?}: 12 pt away, out of reach"); + assert!(labels.is_empty(), "{labels:?}"); + let off = ToolContext { snap_to_last_location: false, ..on }; + assert!(moved_by(moved(&off, (156.0, 155.0), none).0, (6.0, 5.0)), "the option off"); + let no_guides = ToolContext { smart_guides: false, ..on }; + assert!(moved_by(moved(&no_guides, (156.0, 155.0), none).0, (6.0, 5.0)), "Smart Guides off"); + // An Alt copy lines up with the original it leaves behind, as it does with the option off. + let alt = Mods { alt: true, ..none }; + let (copy, labels) = moved(&on, (156.0, 155.0), alt); + assert_eq!((copy, labels.contains(&"last location".to_string())), (moved(&off, (156.0, 155.0), alt).0, false), "{labels:?}"); +} + +/// View › Snap to Pixel wins over Smart Guides › Snap to Last Location, as over every other +/// snap: a short move of a square whose top-left is off the pixel grid puts that corner on whole +/// pixels, a Shift move on its own axis, and shows no "last location" label. +#[test] +fn snap_to_pixel_wins_over_snap_to_last_location() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.3, 100.4, 200.3, 200.4)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, square, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = ToolContext { snap_to_pixel: true, snap_to_last_location: true, snapping_tolerance: 10.0, ..cx(&d, &s, &p) }; + let moved = |to: (f64, f64), m: Mods| { + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0).with_mods(m)); + let a = t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let labels: Vec = + t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect(); + (Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), labels) + }; + // By (6, 5) the top-left (106.3, 105.4) rounds to (106, 105). + let (m, labels) = moved((156.0, 155.0), Mods::default()); + assert!(moved_by(m, (5.7, 4.6)), "{m:?}"); + assert!(labels.is_empty(), "{labels:?}"); + let (m, labels) = moved((157.0, 150.0), Mods { shift: true, ..Mods::default() }); + assert!(moved_by(m, (6.7, 0.0)), "{m:?}: a Shift move"); + assert!(labels.is_empty(), "{labels:?}"); +} + /// A move shows the filled arrow, hollow while it snaps (here B's left side onto A's right /// one); Alt, which leaves a copy behind, the double arrowhead. #[test] diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 0bc29a558..c7a5a1ad4 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -396,6 +396,25 @@ impl TypeTool { out } + /// Stop editing and go back to the tool used before double-clicking into type (or to the + /// Selection tool), and leave the type selected. + fn end_editing(&mut self, cx: &ToolContext) -> Vec { + let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); + let mut out = self.finish(cx); + out.push(Action::SwitchTool(target)); + out + } + + /// Enter types a line break, and Shift+Enter types a forced line break. Cmd+Enter (Ctrl+Enter on + /// Windows and Linux) ends editing as Escape does and keeps the typed text. `Mods` does not tell + /// Ctrl apart from Cmd+Ctrl, so Cmd+Ctrl+Enter on macOS ends editing too. + fn enter(&mut self, cx: &ToolContext, mods: Mods) -> Vec { + if mods.cmd && !mods.shift && !mods.alt { + return self.end_editing(cx); + } + self.replace(cx, if mods.shift { "\u{2028}" } else { "\n" }, None) + } + /// Replace the selection with `insert` (styled `runs` when given) inside the typing session. fn replace(&mut self, cx: &ToolContext, insert: &str, styled: Option>) -> Vec { let Some(id) = self.editing else { return vec![] }; diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index b0494a3e1..4ae1424f6 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -1267,6 +1267,29 @@ fn escape_returns_to_previous_tool_when_set() { assert_eq!(acts2.last(), Some(&Action::SwitchTool("selection".into()))); } +/// Cmd+Enter (Ctrl+Enter on Windows and Linux) ends editing as Escape does and types no line +/// break. Ctrl+Enter on macOS, Shift and Alt with it still type one. +#[test] +fn cmd_enter_ends_editing_like_escape() { + let (d, _id, mut tool) = editing("Hello"); + tool.set_option("prevTool", &json!("directSelection")); + let acts = key(&mut tool, &d, ToolKey::Enter, CMD); + assert!(acts.iter().all(|a| !matches!(a, Action::Preview(..))), "no line break: {acts:?}"); + assert_eq!(acts.last(), Some(&Action::SwitchTool("directSelection".into()))); + assert!(!tool.wants_text(), "editing ended"); + // Ctrl+Enter on Windows and Linux, and Cmd+Ctrl+Enter on macOS. + let cmd_ctrl = Mods { ctrl: true, ..CMD }; + let (d, _id, mut tool) = editing("Hello"); + assert_eq!(key(&mut tool, &d, ToolKey::Enter, cmd_ctrl).last(), Some(&Action::SwitchTool("selection".into()))); + let mac_ctrl = Mods { ctrl: true, ..Mods::default() }; + for mods in [mac_ctrl, Mods { shift: true, ..CMD }, Mods { alt: true, ..CMD }] { + let (d, _id, mut tool) = editing("Hello"); + let acts = key(&mut tool, &d, ToolKey::Enter, mods); + assert!(acts.iter().any(|a| matches!(a, Action::Preview(..))), "{mods:?} types a line break: {acts:?}"); + assert!(tool.wants_text(), "{mods:?} keeps editing"); + } +} + /// A click from `tool` at (x, y) → the actions of the press and the release. fn click(tool: &mut TypeTool, c: &ToolContext, x: f64, y: f64) -> Vec { let mut acts = tool.pointer(c, &PointerEvent::new(PointerKind::Down, x, y)); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 1e9b1be88..9f97f2372 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -228,13 +228,8 @@ impl Tool for TypeTool { // Escape ends editing and goes back to the tool used before double-clicking into type // (or to the Selection tool by default), the type selected: the keyboard is free for // tool shortcuts again (#1686). - ToolKey::Escape => { - let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); - let mut out = self.finish(cx); - out.push(Action::SwitchTool(target)); - out - } - ToolKey::Enter => self.replace(cx, if mods.shift { "\u{2028}" } else { "\n" }, None), + ToolKey::Escape => self.end_editing(cx), + ToolKey::Enter => self.enter(cx, mods), ToolKey::Tab => self.replace(cx, "\t", None), ToolKey::Backspace | ToolKey::Delete if a != b || story => self.replace(cx, "", None), ToolKey::Backspace if self.caret > 0 => { diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 09933d2a4..c3710c1bd 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -648,6 +648,11 @@ impl Tool for ArtboardTool { } } + /// A drag is moving or resizing artboards (Artboard Options › Update While Dragging reads it). + fn transforming(&self) -> bool { + matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. })) + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. })); self.end_drag(); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0b508abfa..a74582972 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -70,6 +70,7 @@ impl VectorcraftApp { last_ms: 0.0, moved: false, from_worker: false, + reused: None, waits_for_still: cfg!(target_arch = "wasm32"), still: None, fill: None, @@ -170,6 +171,7 @@ impl VectorcraftApp { tick_iteration: None, input_frames: vec![], shortcuts_frames: vec![], + held_tool: Vec::new(), synthetic_injected: false, injected_events: vec![], injected_generation: InputGeneration::default(), @@ -255,6 +257,7 @@ impl VectorcraftApp { let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.tick_frame(ctx))) { self.picks.recover(); + self.session.set_replaying(false); self.status(format!("Internal error: {msg} (please report this bug)")); } } @@ -494,6 +497,7 @@ impl VectorcraftApp { }) }) { self.picks.recover(); + self.session.set_replaying(false); self.status(format!("Internal error: {msg} (please report this bug)")); } crate::ime::finish_ui(self, &ctx); @@ -531,8 +535,9 @@ impl VectorcraftApp { if self.ui.status_bar && self.ui.screen_mode < 3 { chrome::status_bar(self, ui); chrome::hint_bar(self, ui); - } else { - // Keeps the previous frame's value valid for menus drawn above, before the bars. + } else if !(self.ui.control_bar && self.ui.control_bar_bottom && self.ui.screen_mode < 2) { + // Keeps the previous frame's value valid for menus drawn above, before the bars. A + // Control bar docked at the bottom has set it to the bar's top. widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); } if self.ui.toolbar && self.ui.screen_mode < 3 { @@ -553,6 +558,8 @@ impl VectorcraftApp { trace_runs::show(self, &ctx); opening::show(self, &ctx); palette::show(self, &ctx); + // After every panel had its chance to draw in this pass. + crate::panels::three_d::end_stale_drag(self, &ctx); if self.custom_titlebar { titlebar::resize_zones(ui); } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index bcd7c578f..985f98c34 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -84,13 +84,24 @@ impl VectorcraftApp { result } + /// [`Self::run`] for saved steps (an action's): keys their commands no longer read are taken, + /// as [`vectorcraft_engine::Session::replay`] takes them, so an action recorded by an older + /// version still plays. + pub fn replay(&mut self, id: &str, params: Value) -> Result { + let outer = self.session.set_replaying(true); + let result = self.run(id, params); + self.session.set_replaying(outer); + result + } + /// [`Self::run`] it, inside what asks for file dialogs. fn run_now(&mut self, id: &str, params: Value) -> Result { crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size); self.run_count = self.run_count.wrapping_add(1); // In debug builds the app's own calls are held to the params descriptions too (#1442): a - // panel passing a key its command doesn't read shows up in the tests. - if cfg!(debug_assertions) { + // panel passing a key its command doesn't read shows up in the tests. Saved steps aren't + // checked ([`Self::replay`]). + if cfg!(debug_assertions) && !self.session.replaying() { menus::check_params(&self.session, id, ¶ms)?; } // A `.ai` file a command writes (a save, an export, a timestamped copy, a batch's save) names @@ -359,8 +370,10 @@ impl VectorcraftApp { /// Select a tool (also used by the toolbar and shortcuts). pub fn select_tool(&mut self, id: &str) { - // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice. + // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice, and + // neither does letting go of a held tool shortcut. self.pen_end.forget_tool(); + self.held_tool.clear(); self.activate_tool(id, true); } diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 2f6443282..213204b95 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -49,6 +49,7 @@ impl VectorcraftApp { Ok(outcome) => outcome, Err(msg) => { self.picks.recover(); + self.session.set_replaying(false); control::err(format!("internal error: {msg} (please report this bug)")) } }; diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index ddb649708..1bb731ca8 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -17,6 +17,10 @@ pub struct CanvasCache { /// The frame shown was rendered on the worker: an interaction's frames stay there /// ([`canvas::show`]). pub from_worker: bool, + /// The frame shown holds pixels the worker copied from its last frame, and when it was shown + /// (seconds, egui's input time): it renders whole once it has been shown for + /// [`canvas::STILL_SECS`] with the view unmoved. + pub reused: Option, /// Set in the web build, which has no render worker: while the view changes on a document whose /// last render took more than 8 ms, the canvas draws the frame shown reprojected and renders /// once the view has been still for [`canvas::STILL_SECS`]. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6210c2826..0793ac9e0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -65,7 +65,7 @@ use overlays::{HandleLook, draw_inside_overlay, draw_overlays, hover_highlight, pub use panes::Panes; pub(crate) use panes::{consolidate, pane, tile, tiled_area}; use raster::{changed_pixels, pixel_region, upload}; -pub(crate) use raster::{color_image, color_image_owned}; +pub(crate) use raster::{color_image, color_image_owned, render_job}; pub use selection_marks::SelectionMarks; use task_bar::task_bar; pub(crate) use task_bar::{has_colors, task_bar_rect}; @@ -297,7 +297,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(secs) = wait { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs)); } - if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) { + // A frame with copied pixels renders whole once it has been shown for STILL_SECS with the view unmoved. + let same_frame = app.canvas.key.as_ref() == Some(&key); + let settle = app.canvas.reused.filter(|_| same_frame).is_some_and(|at| { + let waited = ui.input(|i| i.time) - at; + // The clock went back (a new egui context): don't wait for it to catch up. + let waiting = (0.0..STILL_SECS).contains(&waited); + if waiting { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(STILL_SECS - waited)); + } + !waiting + }); + if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) { let (w, h, view) = frame_view(pixel, (w, h), &v, ppp); let opts = frame_options(app, &key, mask_view, smooth_images); draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel }); @@ -315,6 +326,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } fill_uncovered(app, ui.ctx(), &painter, &key, &doc, rect, &xf); reproject_frame(app, &painter, &xf, rect, (key.doc, v.rotation)); + if fades_outside_artboards(app) { + fade_outside_artboards(&painter, &xf, &doc); + } if let Some(look) = grid_look.filter(|_| !grids_in_back) { grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); } @@ -571,7 +585,11 @@ fn show_worker_frame(app: &mut VectorcraftApp, ctx: &egui::Context, done: crate: app.canvas.key = Some(done.key); app.canvas.shown = Some((done.doc, done.opts)); app.perf.render_ms = done.ms; - app.canvas.last_ms = done.ms; + // A frame with copied pixels leaves `last_ms` at the whole frame's. + if !done.reused { + app.canvas.last_ms = done.ms; + } + app.canvas.reused = done.reused.then(|| ctx.input(|i| i.time)); app.canvas.moved = done.moved > 0; app.canvas.from_worker = true; } @@ -671,6 +689,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { let first = app.canvas.texture.is_none() || !same_doc; let heavy = first || app.canvas.last_ms > 8.0 || settling || staying; let changed = changed_pixels(app, key, doc, &opts, view, w, h); + // A new view can copy from the worker's last frame. The frame shown asked for again renders + // whole. The worker keeps either one for the next pan to copy from. + let may_copy = app.canvas.key.as_ref() != Some(key); let mut landed = None; match (&mut app.canvas.worker, heavy, changed) { // Only the document changed since the frame shown, in a part of it: that part is @@ -697,6 +718,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { view, opts, scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + may_copy, + may_keep: true, }); // A light document's first frame is ready within the grace and shows in this frame, // with no frame of bare paper. Waited for once, when the frame is queued. @@ -714,6 +737,7 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { app.canvas.last_ms = app.perf.render_ms; app.canvas.moved = app.canvas.renderer.stats.moved > 0; app.canvas.from_worker = false; + app.canvas.reused = None; } } if let Some(done) = landed { @@ -806,6 +830,116 @@ fn reproject_frame(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: } } +/// Does the area outside the artboards draw darker? Artboard Options › Fade Region Outside Artboard +/// darkens it while the Artboard tool is active, and with Update While Dragging off it draws as +/// usual while the tool moves or resizes an artboard. Presentation Mode, exports and prints never +/// draw the fade. +fn fades_outside_artboards(app: &VectorcraftApp) -> bool { + let prefs = &app.session.prefs; + app.ui.screen_mode != 3 + && app.session.tool_id() == "artboard" + && prefs.fade_region_outside_artboard + && (prefs.artboard_update_while_dragging || !app.session.tool_transforming()) +} + +/// The fade's color: black at 40% opacity, which shows each channel under it at 60% of its value. +const FADE_OUTSIDE: Color32 = Color32::from_black_alpha(102); + +/// Fade Region Outside Artboard: over the pasteboard and the art on it, one mesh in [`FADE_OUTSIDE`] +/// that covers the canvas outside the artboards of `doc`, drawn through the view. The mesh is not +/// anti-aliased, so its rectangles meet without seams. +fn fade_outside_artboards(painter: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document) { + let boards: Vec = doc.artboards.iter().map(|a| a.rect).collect(); + let mut mesh = egui::Mesh::default(); + for (n, r) in (0u32..).zip(fade_rects(view_bounds(xf), &boards)) { + for p in xf.quad(r) { + mesh.colored_vertex(p, FADE_OUTSIDE); + } + let a = n * 4; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a, a + 2, a + 3); + } + painter.add(Shape::mesh(mesh)); +} + +/// The most rectangles the fade draws exactly around the artboards. +const FADE_MAX_RECTS: usize = 4096; + +/// The rectangles the fade covers: the parts of `bounds` outside the artboards ([`outside_rects`]), +/// or, when those would be more than [`FADE_MAX_RECTS`], the parts outside the box around all the +/// artboards, at most four. +fn fade_rects(bounds: Rect, boards: &[Rect]) -> Vec { + outside_rects(bounds, boards, FADE_MAX_RECTS).unwrap_or_else(|| { + let all: Vec = boards.iter().copied().reduce(|a, b| a.union(b)).into_iter().collect(); + outside_rects(bounds, &all, FADE_MAX_RECTS).unwrap_or_default() + }) +} + +/// The parts of `bounds` that no rectangle of `boards` covers, as rectangles that do not overlap: +/// for each band between consecutive top and bottom edges, the gaps between the boards that cross +/// it. A gap with the same left and right edges as a gap of the band above extends that gap's +/// rectangle down, so each board edge adds at most a few rectangles. Boards that cross each other +/// can leave many more gaps; past `limit` rectangles it stops and returns `None`. +fn outside_rects(bounds: Rect, boards: &[Rect], limit: usize) -> Option> { + let mut ys: Vec = boards.iter().flat_map(|b| [b.y0, b.y1]).filter(|y| *y > bounds.y0 && *y < bounds.y1).collect(); + ys.extend([bounds.y0, bounds.y1]); + ys.sort_by(f64::total_cmp); + ys.dedup(); + let mut by_top: Vec = boards.iter().copied().filter(|b| b.y0 < bounds.y1 && b.y1 > bounds.y0).collect(); + by_top.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + let mut next = 0; + // The boards that cross the current band, left to right. + let mut active: Vec = vec![]; + let mut out: Vec = vec![]; + // The indices in `out` of the band above's rectangles, left to right. + let mut above: Vec = vec![]; + for band in ys.windows(2) { + let &[y0, y1] = band else { continue }; + while let Some(b) = by_top.get(next).copied().filter(|b| b.y0 <= y0) { + let at = active.partition_point(|a| a.x0 <= b.x0); + active.insert(at, b); + next += 1; + } + active.retain(|b| b.y1 >= y1); + let mut gaps = vec![]; + let mut x = bounds.x0; + for b in &active { + let (a, e) = (b.x0.max(bounds.x0), b.x1.min(bounds.x1)); + if a >= e { + continue; + } + if a > x { + gaps.push((x, a)); + } + x = x.max(e); + } + if x < bounds.x1 { + gaps.push((x, bounds.x1)); + } + let mut prev = above.iter().copied().peekable(); + let mut row = Vec::with_capacity(gaps.len()); + for (a, e) in gaps { + while prev.next_if(|&i| out[i].x0 < a).is_some() {} + match prev.peek().copied() { + Some(i) if out[i].x0 == a && out[i].x1 == e => { + out[i].y1 = y1; + row.push(i); + prev.next(); + } + _ => { + row.push(out.len()); + out.push(Rect::new(a, y0, e, y1)); + } + } + } + above = row; + if out.len() > limit { + return None; + } + } + Some(out) +} + /// Do the artboards show? View › Hide Artboards hides them, except for the Artboard tool, which /// edits them. fn shows_artboards(app: &VectorcraftApp) -> bool { diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 1980661b8..63b4ef9c9 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -363,6 +363,10 @@ fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) { /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { + // A press lent to a selection tool (Cmd/Ctrl held) does not use the tool of a held key. + if ev.kind == PointerKind::Down && app.session.lent_tool_id(&ev.mods).is_none() { + crate::shortcuts::note_tool_use(app); + } let r = app.session.pointer(ev, view); // The artboard the Artboard tool makes active is the active one (the navigator's, the // Artboards panel's). diff --git a/crates/ui-egui/src/canvas/fill.rs b/crates/ui-egui/src/canvas/fill.rs index 779f6d352..60d8b0075 100644 --- a/crates/ui-egui/src/canvas/fill.rs +++ b/crates/ui-egui/src/canvas/fill.rs @@ -106,6 +106,8 @@ pub(super) fn fill_uncovered( view, opts, scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + may_copy: false, + may_keep: false, }); } let Some(f) = app.canvas.fill.as_ref().filter(|f| usable(&f.key)) else { return }; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index e0bd9f029..49baf494b 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -290,6 +290,11 @@ fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { crate::document_drag::begin(app, ui.ctx(), ev.pos); Drag::Tool }; + // A drag of the Zoom, Hand or Rotate View tool uses the tool of a held shortcut; held Space's + // view tools are not that tool. + if !space && matches!(d, Drag::ZoomBox { .. } | Drag::ZoomScrub { .. } | Drag::Pan { .. } | Drag::RotateView { .. }) { + crate::shortcuts::note_tool_use(app); + } // Panning, zooming or turning the view takes the pointer from the tool's hover. if !tool_holds(d) { app.session.tool_hover_ended(view); diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index 178a216ec..8684cea05 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -82,6 +82,153 @@ fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) { (a, a + len) } +/// The most bytes of the last frame the render worker keeps: 16 million pixels. A larger frame is +/// not kept, and every frame after it renders whole. +const MAX_KEPT_BYTES: usize = 64 << 20; + +/// How far the view of a frame may be from a whole device pixel, in device pixels, for the frame's +/// pixels to be copied. +const WHOLE_PIXEL: f64 = 1e-6; + +/// The render worker's last frame, which its next frame can copy pixels from when it holds them. +pub(crate) struct KeptFrame { + key: CacheKey, + opts: vectorcraft_render::RenderOptions, + view: Affine, + pub(crate) pixels: Option>, +} + +/// A frame the render worker finished. +pub(crate) struct WorkerFrame { + pub(crate) img: vectorcraft_render::Rendered, + /// [`vectorcraft_render::FrameStats::moved`] of the renders that made the frame. + pub(crate) moved: usize, + /// The frame holds pixels copied from the last frame. + pub(crate) reused: bool, +} + +/// Nothing but the position of the view differs between the frame `kept` and the job `job`, in +/// their keys, render options and views, and the frame is not Pixel Preview's and shows no opacity +/// mask. +fn only_position_differs(kept: &KeptFrame, job: &crate::render_worker::Job) -> bool { + let (a, b) = (&kept.key, &job.key); + let same_scale = a.zoom == b.zoom && a.rot == b.rot && a.ppp == b.ppp && (a.w, a.h) == (b.w, b.h) && (job.w, job.h) == (b.w, b.h); + same_scale + && super::cursor::only_view_differs(a, b) + && a.pixel.is_none() + && b.pixel.is_none() + && kept.opts == job.opts + && job.opts.mask_view.is_none() + && kept.view.as_coeffs()[..4] == job.view.as_coeffs()[..4] +} + +/// How many whole device pixels the art moves between the frame `kept` and the job `job`, when +/// `job` can copy pixels from it: `job` allows it, nothing but the position of the view differs +/// ([`only_position_differs`]), and the art moves by whole device pixels. +fn whole_pixel_pan(kept: &KeptFrame, job: &crate::render_worker::Job) -> Option<(i64, i64)> { + if !job.may_copy || !only_position_differs(kept, job) { + return None; + } + let (from, to) = (kept.view.as_coeffs(), job.view.as_coeffs()); + let whole = |d: f64| (d.is_finite() && (d - d.round()).abs() <= WHOLE_PIXEL && d.abs() < f64::from(u16::MAX)).then_some(d.round() as i64); + Some((whole(to[4] - from[4])?, whole(to[5] - from[5])?)) +} + +/// The parts `[x0, y0, x1, y1]` of a `w`×`h` frame that a pan by `(dx, dy)` device pixels exposes, +/// grown to whole eighths of the frame. None when they cover more than half of it. +fn exposed_parts(w: u32, h: u32, (dx, dy): (i64, i64)) -> Option> { + // How much of 0..n a move by `d` exposes, at the start for a positive `d`, else at the end. + let span = |d: i64, n: u32| { + let len = u32::try_from(d.unsigned_abs()).ok()?.min(n); + Some(in_steps(if d > 0 { 0 } else { n - len }, if d > 0 { len } else { n }, n)) + }; + let mut parts = Vec::with_capacity(2); + if dx != 0 { + let (x0, x1) = span(dx, w)?; + parts.push([x0, 0, x1, h]); + } + if dy != 0 { + let (y0, y1) = span(dy, h)?; + parts.push([0, y0, w, y1]); + } + let area: u64 = parts.iter().map(|[x0, y0, x1, y1]| u64::from(x1 - x0) * u64::from(y1 - y0)).sum(); + (area * 2 <= u64::from(w) * u64::from(h)).then_some(parts) +} + +/// `pixels` of a `w`×`h` frame with the art moved by `(dx, dy)` device pixels. The pixels the art +/// does not reach stay transparent. +fn moved_pixels(pixels: &[u8], w: u32, h: u32, (dx, dy): (i64, i64)) -> Vec { + let (w, h) = (i64::from(w), i64::from(h)); + let mut out = vec![0; pixels.len()]; + let (x0, x1) = (dx.max(0), (w + dx).min(w)); + if x1 > x0 { + for y in dy.max(0)..(h + dy).min(h) { + let (from, to, n) = (((y - dy) * w + x0 - dx) as usize * 4, (y * w + x0) as usize * 4, (x1 - x0) as usize * 4); + out[to..to + n].copy_from_slice(&pixels[from..from + n]); + } + } + out +} + +/// The frame of `job` from `pixels`, the last frame's moved by the pan, and the exposed `parts` +/// rendered on their own, with its [`vectorcraft_render::FrameStats::moved`]. None when a part draws +/// art in a way that depends on the frame's size ([`vectorcraft_render::FrameStats::view_sized`]). +fn composed_frame( + r: &mut vectorcraft_render::Renderer, + mut pixels: Vec, + parts: Vec<[u32; 4]>, + job: &crate::render_worker::Job, +) -> Option<(vectorcraft_render::Rendered, usize)> { + let mut moved = 0; + let stride = job.w as usize * 4; + for [x0, y0, x1, y1] in parts { + let view = Affine::translate((-f64::from(x0), -f64::from(y0))) * job.view; + let part = r.render(&job.doc, x1 - x0, y1 - y0, view, &job.opts); + if r.stats.view_sized > 0 { + return None; + } + moved += r.stats.moved; + let row = (x1 - x0) as usize * 4; + for (i, line) in part.pixels.chunks_exact(row).enumerate() { + let to = (y0 as usize + i) * stride + x0 as usize * 4; + pixels[to..to + row].copy_from_slice(line); + } + } + Some((vectorcraft_render::Rendered { width: job.w, height: job.h, pixels }, moved)) +} + +/// Render the frame of `job`. A pan by whole device pixels copies the pixels that stay on screen +/// from `kept`, the frame before it, and renders only the parts it exposes. Any other frame renders +/// whole, and so does a pan when a part it exposes draws art in a way that depends on the frame's +/// size ([`vectorcraft_render::FrameStats::view_sized`]). A copied frame can differ from the same +/// frame rendered whole (by at most one level in the tests), so the canvas renders a frame whole +/// once it has been shown for [`super::STILL_SECS`] with the view unmoved +/// ([`crate::CanvasCache::reused`]). `kept` becomes this frame when `job` allows keeping it, it is +/// at most [`MAX_KEPT_BYTES`], it draws no raster from before its object moved, and it draws nothing +/// in a way that depends on its size. That whole frame is kept too, so the first step of the next +/// pan copies from it. A kept frame holds its pixels only when the worker kept no frame before it or +/// nothing but the position of the view differs from that frame. A frame after a zoom or an edit +/// holds none, so it costs no copy, and the first pan step after it renders whole. +pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option, job: &crate::render_worker::Job) -> WorkerFrame { + let holds_pixels = kept.as_ref().is_none_or(|k| only_position_differs(k, job)); + let copied = kept.as_ref().and_then(|k| { + let pan = whole_pixel_pan(k, job)?; + let parts = exposed_parts(job.w, job.h, pan)?; + composed_frame(r, moved_pixels(k.pixels.as_ref()?, job.w, job.h, pan), parts, job) + }); + let reused = copied.is_some(); + let (img, moved) = copied.unwrap_or_else(|| { + let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts); + let moved = r.stats.moved; + (img, moved) + }); + let keeps = + job.may_keep && moved == 0 && r.stats.view_sized == 0 && img.pixels.len() <= MAX_KEPT_BYTES && (img.width, img.height) == (job.w, job.h); + *kept = + keeps.then(|| KeptFrame { key: job.key.clone(), opts: job.opts.clone(), view: job.view, pixels: holds_pixels.then(|| img.pixels.clone()) }); + WorkerFrame { img, moved, reused } +} + /// Upload the art's raster directly into the canvas texture without reallocating or copying pixels; /// Pixel Preview's document pixels keep hard edges when magnified. pub(super) fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: vectorcraft_render::Rendered, pixel: bool) { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 84788bb70..d9fa7f09c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1602,13 +1602,18 @@ fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { } /// Two headless canvas frames → the vertices of the second one's tessellated mesh, which the -/// GPU takes in one buffer. +/// GPU takes in one buffer, without the art's raster: the render worker's frame can land in +/// either frame of a comparison. fn frame_vertices(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { frame(app, ctx, vec![]); let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| show(app, ui)); out.textures_delta.clear(); - let mesh = |p: &egui::ClippedPrimitive| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { m.vertices.len() } else { 0 }; + let art = app.canvas.texture.as_ref().map(|t| t.id()); + let mesh = |p: &egui::ClippedPrimitive| match &p.primitive { + egui::epaint::Primitive::Mesh(m) if Some(m.texture_id) != art => m.vertices.len(), + _ => 0, + }; ctx.tessellate(out.shapes, out.pixels_per_point).iter().map(mesh).sum() } @@ -2438,7 +2443,7 @@ fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() { // Deliver the actual replacement and verify that it replaces the cached key. 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 }).unwrap(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.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"); } @@ -2581,11 +2586,12 @@ fn pan_fills_the_area_the_last_frame_leaves() { let fill_job = fill_jobs.try_recv().expect("the fill is on the fill worker"); let (fw, fh, _) = fill_view(&job.key); assert!(fill_job.opts.kept_images_only && (fill_job.w, fill_job.h) == (fw, fh), "{:?}", (fill_job.w, fill_job.h)); + assert!(!fill_job.may_copy && !fill_job.may_keep, "the fill worker copies nothing and holds no frame"); assert!(app.canvas.fill.is_none(), "nothing to draw until the fill arrives"); // The fill arrives; the next frame draws it, and asks for no other fill. let img = app.canvas.renderer.render(&fill_job.doc, fill_job.w, fill_job.h, fill_job.view, &fill_job.opts); let (doc, opts) = (fill_job.doc, fill_job.opts); - fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0 }).unwrap(); + fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0, reused: false }).unwrap(); let (meshes, pixels) = frame(&mut app); assert!(fill_jobs.try_recv().is_err(), "the fill covers the canvas"); assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown"); @@ -2630,11 +2636,11 @@ fn pan_fills_the_area_the_last_frame_leaves() { app.view_mut().unwrap().center = Point::new(250.0, 150.0); // That fill finishes after the worker's frame: it is taken and not kept. let img = app.canvas.renderer.render(&next.doc, next.w, next.h, next.view, &next.opts); - fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0 }).unwrap(); + fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0, reused: false }).unwrap(); // The worker's frame arrives: it covers the canvas, and the fill is no longer drawn or kept. 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: 50.0, moved: 0 }).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(); let (meshes, _) = frame(&mut app); assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame is shown"); assert!(app.canvas.fill.is_none() && meshes.iter().all(|m| m.texture_id != fill_tex), "the fill is dropped"); @@ -6158,6 +6164,32 @@ fn clear_history_waits_while_the_ime_composes() { assert!(crate::menus::enabled(&app, "edit.clearHistory")); } +/// New Document from State waits while an IME composes, as Undo does: no document is made and the +/// composition goes on. +#[test] +fn new_document_from_state_waits_while_the_ime_composes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + typing_frame(&mut app, &ctx, vec![preedit("が", 1)]); + assert!(app.session.tool_composing()); + assert!(!crate::menus::enabled(&app, "edit.newDocumentFromState")); + crate::menus::invoke(&mut app, "edit.newDocumentFromState", json!({})); + assert!(app.session.tool_composing(), "the composition goes on"); + assert_eq!(app.session.documents().len(), 1); + typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅".into()))]); + assert!(crate::menus::enabled(&app, "edit.newDocumentFromState")); +} + #[test] fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -6344,7 +6376,7 @@ fn editing_keys_after_an_ime_commit_apply_in_the_same_frame() { } /// Cmd/Ctrl+Enter bound to a command runs the command and types no line break, also when a plain -/// Enter comes in the same frame. Unbound, it types a line break as Enter does. +/// Enter comes in the same frame. Unbound, it ends editing and keeps the text. #[test] fn a_command_on_cmd_enter_runs_without_typing_a_line_break() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -6359,18 +6391,83 @@ fn a_command_on_cmd_enter_runs_without_typing_a_line_break() { modifiers: egui::Modifiers::COMMAND, }); typing_frame(&mut app, &ctx, vec![egui::Event::Text("a".into())]); - typing_frame(&mut app, &ctx, cmd_enter.to_vec()); - assert_eq!(plain(&app, id), "a\n", "nothing is bound to Cmd+Enter"); crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.grid", "Cmd+Enter"); crate::shortcut_editor::sync(&app.ui); let grid = app.ui.view.grid; typing_frame(&mut app, &ctx, cmd_enter.to_vec()); - let ran = (plain(&app, id), app.ui.view.grid); + let ran = (plain(&app, id), app.ui.view.grid, app.session.tool_wants_text()); typing_frame(&mut app, &ctx, [tap(egui::Key::Enter).to_vec(), cmd_enter.to_vec()].concat()); app.ui.shortcut_overrides.clear(); crate::shortcut_editor::sync(&app.ui); - assert_eq!((ran.0.as_str(), ran.1), ("a\n", !grid), "Cmd+Enter runs Show Grid only"); - assert_eq!((plain(&app, id).as_str(), app.ui.view.grid), ("a\n\n", grid), "Enter types a line break and Cmd+Enter runs Show Grid"); + assert_eq!((ran.0.as_str(), ran.1, ran.2), ("a", !grid, true), "Cmd+Enter runs Show Grid only"); + assert_eq!((plain(&app, id).as_str(), app.ui.view.grid), ("a\n", grid), "Enter types a line break and Cmd+Enter runs Show Grid"); + assert!(app.session.tool_wants_text(), "a bound Cmd+Enter does not end editing"); + typing_frame(&mut app, &ctx, cmd_enter.to_vec()); + assert_eq!(plain(&app, id), "a\n", "nothing is bound to Cmd+Enter: no line break"); + assert!(!app.session.tool_wants_text(), "unbound, Cmd+Enter ends editing"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// Cmd+Enter (Ctrl+Enter on Windows and Linux) ends editing like Escape: a repeat of it, and text, +/// keys and a paste after it in the same frame, leave the type as it was. Enter, Shift+Enter and +/// Ctrl+Enter on macOS still type line breaks. +#[test] +fn cmd_enter_ends_text_editing_and_keeps_the_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + let ctx = egui::Context::default(); + let id = start_typing(&mut app, &ctx); + let key = |pressed, repeat, modifiers| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat, modifiers }; + let mac_ctrl = egui::Modifiers { ctrl: true, ..Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::Text("a".into())]); + typing_frame(&mut app, &ctx, vec![key(true, false, egui::Modifiers::NONE), key(false, false, egui::Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![key(true, false, egui::Modifiers::SHIFT), key(false, false, egui::Modifiers::SHIFT)]); + typing_frame(&mut app, &ctx, vec![key(true, false, mac_ctrl), key(false, false, mac_ctrl)]); + assert_eq!(plain(&app, id), "a\n\u{2028}\n"); + assert!(app.session.tool_wants_text()); + let held = vec![ + key(true, false, egui::Modifiers::COMMAND), + key(true, true, egui::Modifiers::COMMAND), + egui::Event::Text("b".into()), + egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + egui::Event::Paste("c".into()), + egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + ]; + typing_frame(&mut app, &ctx, held); + assert!(!app.session.tool_wants_text(), "editing ended"); + assert_eq!(app.session.tool_id(), "selection"); + assert_eq!(plain(&app, id), "a\n\u{2028}\n", "the text is kept, and nothing after the chord changes it"); + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(id)], "the type stays selected"); + typing_frame(&mut app, &ctx, vec![key(true, true, egui::Modifiers::COMMAND)]); + typing_frame(&mut app, &ctx, vec![key(false, false, egui::Modifiers::COMMAND)]); + assert!(app.ui.dialog.is_none(), "the repeats of the chord open no dialog"); + assert_eq!(plain(&app, id), "a\n\u{2028}\n"); +} + +/// While the IME composes, Cmd+Enter is the IME's: it does not end editing, drop the marked text or +/// type a line break, in the frame that starts the composition or in a later one. +#[test] +fn cmd_enter_while_composing_keeps_the_composition_and_editing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + let ctx = egui::Context::default(); + let id = start_typing(&mut app, &ctx); + let cmd_enter = [true, false].map(|pressed| egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::COMMAND, + }); + typing_frame(&mut app, &ctx, vec![egui::Event::Text("a".into())]); + typing_frame(&mut app, &ctx, [vec![preedit("か", 1)], cmd_enter.to_vec()].concat()); + assert!(app.session.tool_composing(), "the same frame keeps the composition"); + assert!(app.session.tool_wants_text(), "the same frame keeps editing"); + assert_eq!(plain(&app, id), "aか", "the same frame keeps the marked text and types no line break"); + typing_frame(&mut app, &ctx, cmd_enter.to_vec()); + assert!(app.session.tool_composing(), "a later frame keeps the composition"); + assert!(app.session.tool_wants_text(), "a later frame keeps editing"); + assert_eq!(plain(&app, id), "aか", "a later frame keeps the marked text and types no line break"); } /// Reset to a blank document, select the Type tool and click document point (100, 100), returning @@ -9015,6 +9112,205 @@ fn hidden_artboards_leave_a_paper_canvas_without_edges() { assert_eq!(look(&mut app), (false, true)); } +/// One headless canvas frame → whether the fade outside the artboards is drawn. +fn fades(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { + fade_mesh(&shapes(app, ctx)).is_some() +} + +/// The fade outside the artboards among `shapes`, with its position: the mesh in [`FADE_OUTSIDE`]. +fn fade_mesh(shapes: &[Shape]) -> Option<(usize, &egui::Mesh)> { + shapes.iter().enumerate().find_map(|(i, s)| match s { + Shape::Mesh(m) if !m.vertices.is_empty() && m.vertices.iter().all(|v| v.color == FADE_OUTSIDE) => Some((i, &**m)), + _ => None, + }) +} + +/// Does `mesh` cover the screen point `p`? +fn mesh_covers(mesh: &egui::Mesh, p: Pos2) -> bool { + let cross = |a: Pos2, b: Pos2| (b - a).x * (p - a).y - (b - a).y * (p - a).x; + mesh.indices.chunks(3).any(|t| { + let [a, b, c] = [t[0], t[1], t[2]].map(|i| mesh.vertices[i as usize].pos); + let (d0, d1, d2) = (cross(a, b), cross(b, c), cross(c, a)); + (d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0) + }) +} + +/// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active the area +/// outside the artboards draws darker; with another tool, or with the preference off, it draws as +/// usual. +#[test] +fn the_artboard_tool_fades_the_pasteboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + assert_eq!(FADE_OUTSIDE.a(), 102, "black at 40%: each channel under it shows at 60%"); + assert!(app.session.prefs.fade_region_outside_artboard, "on by default"); + assert!(!fades(&mut app, &ctx), "the Selection tool"); + app.select_tool("artboard"); + assert!(fades(&mut app, &ctx), "the Artboard tool"); + app.select_tool("rectangle"); + assert!(!fades(&mut app, &ctx), "the Rectangle tool"); + app.run("prefs.set", json!({"key": "fadeRegionOutsideArtboard", "value": false})).unwrap(); + app.select_tool("artboard"); + assert!(!fades(&mut app, &ctx), "the preference off"); +} + +/// Presentation Mode shows only the art: with the Artboard tool active and the fade on, it draws no +/// fade, and the fade comes back when Presentation Mode ends. +#[test] +fn presentation_mode_draws_no_fade() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("artboard"); + assert!(fades(&mut app, &ctx), "the Artboard tool"); + app.run("view.presentation", json!({})).unwrap(); + assert_eq!(app.ui.screen_mode, 3); + assert_eq!(app.session.tool_id(), "artboard"); + assert!(!fades(&mut app, &ctx), "Presentation Mode"); + app.run("view.presentation", json!({})).unwrap(); + assert!(fades(&mut app, &ctx), "after Presentation Mode"); +} + +/// A row of boards aligned at the top, each a different height, gives a number of parts that grows +/// with the number of boards, not with its square, and the parts still add up to the uncovered area. +#[test] +fn outside_rects_of_a_row_of_boards_stay_few() { + let n = 200; + let boards: Vec = + (0..n).map(|i| Rect::new(20.0 * f64::from(i) + 10.0, 10.0, 20.0 * f64::from(i) + 20.0, 20.0 + 10.0 * f64::from(i))).collect(); + let bounds = Rect::new(0.0, 0.0, 20.0 * f64::from(n) + 10.0, 10.0 * f64::from(n) + 40.0); + let parts = outside_rects(bounds, &boards, usize::MAX).unwrap(); + assert!(parts.len() <= 3 * n as usize, "{} parts for {n} boards", parts.len()); + let area = |r: &Rect| r.width() * r.height(); + let covered: f64 = boards.iter().map(area).sum(); + assert_eq!(parts.iter().map(area).sum::(), area(&bounds) - covered); +} + +/// 500 narrow tall boards crossed by 500 narrow wide ones leave about 250,000 gaps between them. +/// The fade then covers only the parts outside the box around all the boards, so it stays a few +/// rectangles. +#[test] +fn the_fade_of_crossing_boards_stays_bounded() { + let n = 500; + let side = 4.0 * f64::from(n); + let boards: Vec = (0..n) + .flat_map(|i| { + let at = 4.0 * f64::from(i); + [Rect::new(at, 0.0, at + 1.0, side), Rect::new(0.0, at, side, at + 1.0)] + }) + .collect(); + let bounds = Rect::new(-100.0, -100.0, side + 100.0, side + 100.0); + assert!(outside_rects(bounds, &boards, FADE_MAX_RECTS).is_none(), "more than {FADE_MAX_RECTS} gaps"); + let parts = fade_rects(bounds, &boards); + assert!(parts.len() <= 4, "{} parts", parts.len()); + let area = |r: &Rect| r.width() * r.height(); + let all = Rect::new(0.0, 0.0, side, side); + assert_eq!(parts.iter().map(area).sum::(), area(&bounds) - area(&all)); +} + +/// The fade covers the art that lies on the pasteboard as well as the pasteboard: it is drawn over +/// the art, over a point of an object outside the artboard and not over a point of an object on it, +/// in an upright view and in a rotated one. +#[test] +fn the_fade_darkens_art_on_the_pasteboard_and_not_on_the_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": -150, "y": 100, "width": 100, "height": 100})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); // the first frame fits the view to the artboard + app.view_mut().unwrap().zoom = 0.5; + app.select_tool("artboard"); + for rotation in [0.0, 30.0] { + app.view_mut().unwrap().rotation = rotation; + let s = shapes(&mut app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art"); + let (at, mesh) = fade_mesh(&s).expect("the fade"); + assert!(at > image, "over the art, rotation {rotation}"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + assert!(mesh_covers(mesh, xf.to_screen(Point::new(-100.0, 150.0))), "the object outside, rotation {rotation}"); + assert!(!mesh_covers(mesh, xf.to_screen(Point::new(200.0, 150.0))), "the object on the artboard, rotation {rotation}"); + assert!(mesh_covers(mesh, xf.to_screen(Point::new(200.0, -40.0))), "the pasteboard, rotation {rotation}"); + } +} + +/// The parts outside the boards: none where boards cover the bounds, the bounds without boards, and +/// otherwise rectangles that do not overlap a board or each other and add up to the uncovered area. +#[test] +fn outside_rects_cover_the_bounds_minus_the_boards() { + let bounds = Rect::new(0.0, 0.0, 100.0, 100.0); + assert!(fade_rects(bounds, &[Rect::new(-10.0, -10.0, 110.0, 110.0)]).is_empty()); + assert_eq!(fade_rects(bounds, &[]), vec![bounds]); + let boards = [Rect::new(10.0, 10.0, 40.0, 40.0), Rect::new(30.0, 30.0, 60.0, 80.0), Rect::new(90.0, -20.0, 130.0, 50.0)]; + let parts = fade_rects(bounds, &boards); + let area = |r: &Rect| r.width() * r.height(); + let overlap = |a: &Rect, b: &Rect| (a.x1.min(b.x1) - a.x0.max(b.x0)).max(0.0) * (a.y1.min(b.y1) - a.y0.max(b.y0)).max(0.0); + for (i, p) in parts.iter().enumerate() { + assert!(boards.iter().all(|b| overlap(p, b) == 0.0), "{p:?} overlaps a board"); + assert!(parts[i + 1..].iter().all(|q| overlap(p, q) == 0.0), "{p:?} overlaps another part"); + } + // Inside the bounds the boards cover 900 + 1,500 less their 100 of overlap, and 500 of the third. + assert_eq!(parts.iter().map(area).sum::(), 10_000.0 - (900.0 + 1_500.0 - 100.0 + 500.0)); +} + +/// Artboard Options › Update While Dragging: on, the pasteboard stays darker while the Artboard tool +/// moves or resizes an artboard; off, it draws as usual during the drag and darker again after it. +#[test] +fn update_while_dragging_keeps_or_drops_the_fade_during_an_artboard_drag() { + for update in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "artboardUpdateWhileDragging", "value": update})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let r = app.session.active().unwrap().doc.artboards[0].rect; + let view = app.view_info(); + // A move from the artboard's middle, then a resize from its right edge. + let (cx, cy) = (r.center().x, r.center().y); + for (from, to) in + [(Point::new(cx, cy), Point::new(cx + 40.0, cy + 30.0)), (Point::new(r.x1 + 40.0, cy + 30.0), Point::new(r.x1 + 90.0, cy + 30.0))] + { + dispatch(&mut app, &PointerEvent::new(PointerKind::Down, from.x, from.y), view); + for p in [from + (to - from) * 0.5, to] { + dispatch(&mut app, &PointerEvent::new(PointerKind::Drag, p.x, p.y), view); + } + assert!(app.session.tool_transforming(), "the artboard is being dragged"); + assert_eq!(fades(&mut app, &ctx), update, "Update While Dragging {update}"); + dispatch(&mut app, &PointerEvent::new(PointerKind::Up, to.x, to.y), view); + assert!(!app.session.tool_transforming()); + assert!(fades(&mut app, &ctx), "darker again after the drag"); + } + let moved = app.session.active().unwrap().doc.artboards[0].rect; + assert!(moved.x0 > r.x0 && moved.width() > r.width(), "moved, then resized: {moved:?} from {r:?}"); + } +} + +/// The fade is drawn only on the canvas: exports with the Artboard tool active and the fade on are +/// the same as with the Selection tool and the fade off. +#[test] +fn the_pasteboard_fade_leaves_exports_unchanged() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": -40, "y": 20, "width": 120, "height": 40})).unwrap(); + let exports = |app: &mut VectorcraftApp| -> Vec { + ["svg", "png"].iter().map(|f| app.run("document.export", json!({"format": f})).unwrap()).collect() + }; + app.run("prefs.set", json!({"key": "fadeRegionOutsideArtboard", "value": false})).unwrap(); + let plain = exports(&mut app); + app.run("prefs.set", json!({"key": "fadeRegionOutsideArtboard", "value": true})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + assert!(fades_outside_artboards(&app)); + assert_eq!(exports(&mut app), plain); +} + /// Finished worker renders are uploaded whenever they are not older than what is currently /// displayed on screen. During continuous edit streams (drag previews, held arrow nudges, or /// command bursts) where the document revision advances every frame, intermediate worker frames @@ -9055,6 +9351,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra img: img5, ms: 2.5, moved: 0, + reused: false, }) .unwrap(); @@ -9077,6 +9374,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra img: img3, ms: 2.5, moved: 0, + reused: false, }) .unwrap(); @@ -9098,6 +9396,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra img: img8, ms: 2.5, moved: 0, + reused: false, }) .unwrap(); @@ -9119,6 +9418,7 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra img: img10, ms: 2.5, moved: 0, + reused: false, }) .unwrap(); @@ -9206,7 +9506,493 @@ fn the_contour_bar_sets_corners_and_bakes() { /// A frame the render worker finished for `job`: white pixels of the job's size. fn finished(job: &crate::render_worker::Job) -> crate::render_worker::Done { let img = vectorcraft_render::Rendered { width: job.w, height: job.h, pixels: vec![255; job.w as usize * job.h as usize * 4] }; - crate::render_worker::Done { key: job.key.clone(), doc: job.doc.clone(), opts: job.opts.clone(), img, ms: 40.0, moved: 0 } + crate::render_worker::Done { key: job.key.clone(), doc: job.doc.clone(), opts: job.opts.clone(), img, ms: 40.0, moved: 0, reused: false } +} + +/// A job for the render worker to pan over a document of `doc`: a view at 100% moved by `(dx, dy)` +/// device pixels, in a 256×128 frame. +fn pan_job(doc: &std::sync::Arc, key: &CacheKey, (dx, dy): (f64, f64)) -> crate::render_worker::Job { + crate::render_worker::Job { + key: CacheKey { cx: 128.0 - dx, cy: 64.0 - dy, w: 256, h: 128, ..key.clone() }, + doc: doc.clone(), + w: 256, + h: 128, + view: Affine::translate((dx, dy)), + opts: vectorcraft_render::RenderOptions::default(), + scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + may_copy: true, + may_keep: true, + } +} + +/// The document and the canvas key the render worker tests start from: rectangles on whole pixels. +fn pan_doc() -> (std::sync::Arc, CacheKey) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 256, "height": 128})).unwrap(); + for (x, color) in [(16, "#c02040"), (96, "#2040c0"), (176, "#20a040")] { + let id = app.run("shape.rectangle", json!({"x": x, "y": 16, "width": 64, "height": 96})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "color": color})).unwrap(); + } + app.canvas.worker_started = true; + shapes(&mut app, &egui::Context::default()); + let key = app.canvas.key.clone().expect("a frame was rendered"); + (app.session.active().unwrap().doc.clone(), CacheKey { zoom: 1.0, rot: 0.0, ppp: 1.0, pixel: None, ..key }) +} + +/// A pan by whole device pixels takes the pixels that stay on screen from the last frame: a mark +/// put in that frame moves with the pan, and the frame matches a whole render where the mark is not. +#[test] +fn a_whole_pixel_pan_copies_the_pixels_that_stay_on_screen() { + let (doc, key) = pan_doc(); + for pan in [(10.0, 0.0), (-10.0, 0.0), (0.0, 7.0), (0.0, -7.0), (-13.0, 5.0)] { + let mut r = vectorcraft_render::Renderer::new(); + let mut kept = None; + let first = render_job(&mut r, &mut kept, &pan_job(&doc, &key, (0.0, 0.0))); + assert!(!first.reused, "the first frame renders whole"); + let k = kept.as_mut().and_then(|k| k.pixels.as_mut()).expect("the frame holds its pixels"); + // The middle of the frame, which stays on screen. + k[(64 * 256 + 128) * 4..(64 * 256 + 128) * 4 + 4].copy_from_slice(&[1, 2, 3, 4]); + let job = pan_job(&doc, &key, pan); + let frame = render_job(&mut r, &mut kept, &job); + assert!(frame.reused, "{pan:?}"); + let at = ((64 + pan.1 as i64) * 256 + 128 + pan.0 as i64) as usize * 4; + assert_eq!(frame.img.pixels[at..at + 4], [1, 2, 3, 4], "{pan:?}: the marked pixel moved with the art"); + let whole = vectorcraft_render::Renderer::new().render(&doc, 256, 128, job.view, &job.opts); + let mut img = frame.img.pixels; + img[at..at + 4].copy_from_slice(&whole.pixels[at..at + 4]); + assert_eq!(img, whole.pixels, "{pan:?}: the same picture as a whole render"); + } +} + +/// A frame does not copy from the last one when the view moves by part of a pixel or over half the +/// frame, when copying is not allowed, or when a listed key field, render option, zoom, rotation, +/// canvas size or device scale differs. Each such frame has the job's size. The session test +/// compares such frames with a fresh render pixel by pixel. +#[test] +fn a_frame_renders_whole_when_anything_but_a_whole_pixel_pan_changed() { + let (doc, key) = pan_doc(); + let other_opts = |f: &dyn Fn(&mut vectorcraft_render::RenderOptions)| { + let mut job = pan_job(&doc, &key, (10.0, 0.0)); + f(&mut job.opts); + job + }; + let other_key = |f: &dyn Fn(&mut CacheKey)| { + let mut job = pan_job(&doc, &key, (10.0, 0.0)); + f(&mut job.key); + job + }; + let mut other_doc = (*doc).clone(); + other_doc.layers.clear(); + let scaled = { + let mut job = pan_job(&doc, &key, (10.0, 0.0)); + job.key.zoom = 2.0; + job.view = Affine::translate((10.0, 0.0)) * Affine::scale(2.0); + job + }; + let turned = { + let mut job = pan_job(&doc, &key, (10.0, 0.0)); + job.key.rot = 30.0; + job.view = Affine::translate((10.0, 0.0)) * Affine::rotate(30f64.to_radians()); + job + }; + let resized = { + let mut job = pan_job(&doc, &key, (10.0, 0.0)); + (job.key.w, job.w) = (200, 200); + job + }; + let denser = other_key(&|k| k.ppp = 2.0); + let not_allowed = crate::render_worker::Job { may_copy: false, ..pan_job(&doc, &key, (10.0, 0.0)) }; + let cases: Vec<(&str, crate::render_worker::Job)> = vec![ + ("part of a pixel across", pan_job(&doc, &key, (10.5, 0.0))), + ("part of a pixel down", pan_job(&doc, &key, (0.0, 0.25))), + ("too far to keep half", pan_job(&doc, &key, (250.0, 0.0))), + ("zoom", scaled), + ("rotation", turned), + ("canvas size", resized), + ("device scale", denser), + ("not allowed", not_allowed), + ("document", other_key(&|k| k.doc += 1)), + ("revision", crate::render_worker::Job { doc: std::sync::Arc::new(other_doc), ..other_key(&|k| k.revision += 1) }), + ("interaction", other_key(&|k| k.interacting = true)), + ("outline key", other_key(&|k| k.outline = true)), + ("Pixel Preview", other_key(&|k| k.pixel = Some([0, 0, 256, 128]))), + ("placed bitmaps", other_key(&|k| k.placed += 1)), + ("outline", other_opts(&|o| o.outline = true)), + ("proof", other_opts(&|o| o.proof = Some(Default::default()))), + ("overprint preview", other_opts(&|o| o.overprint_preview = true)), + ("rich black", other_opts(&|o| o.rich_black = true)), + ("trim", other_opts(&|o| o.trim = true)), + ("anti-alias", other_opts(&|o| o.anti_alias = vectorcraft_render::AntiAlias::None)), + ("smooth images", other_opts(&|o| o.smooth_images = false)), + ("opacity mask view", other_opts(&|o| o.mask_view = Some(vectorcraft_doc::NodeId(1)))), + ]; + let mut copied = vec![]; + for (name, job) in cases { + let mut r = vectorcraft_render::Renderer::new(); + let mut kept = None; + render_job(&mut r, &mut kept, &pan_job(&doc, &key, (0.0, 0.0))); + let frame = render_job(&mut r, &mut kept, &job); + if frame.reused { + copied.push(name); + } + let whole = vectorcraft_render::Renderer::new().render(&job.doc, job.w, job.h, job.view, &job.opts); + assert_eq!((frame.img.width, frame.img.height), (whole.width, whole.height), "{name}"); + } + assert!(copied.is_empty(), "copied from the last frame: {copied:?}"); +} + +/// The worker keeps a frame only when a later one can copy from it: a frame that does not allow +/// keeping, a frame over the limit and a frame that drew a raster from before its object moved are +/// not kept. A frame that may not copy can still be kept. +#[test] +fn the_render_worker_keeps_one_frame_up_to_its_limit() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 256, "height": 128})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 16, "y": 16, "width": 64, "height": 64})).unwrap()["id"].clone(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + let after = app.session.active().unwrap().doc.clone(); + let (_, key) = pan_doc(); + let mut r = vectorcraft_render::Renderer::new(); + let mut kept = None; + render_job(&mut r, &mut kept, &pan_job(&before, &key, (0.0, 0.0))); + assert!(kept.is_some(), "kept"); + let mut moved = pan_job(&after, &CacheKey { revision: key.revision + 1, interacting: true, ..key.clone() }, (0.0, 0.0)); + moved.opts.interacting = true; + let frame = render_job(&mut r, &mut kept, &moved); + assert!(frame.moved > 0, "the shadow drew from the raster made before the move"); + let mut wrong = vec![]; + if kept.is_some() { + wrong.push("a frame that drew a raster from before its object moved"); + } + + let (doc, key) = pan_doc(); + render_job(&mut r, &mut kept, &pan_job(&doc, &key, (0.0, 0.0))); + assert!(kept.is_some(), "kept"); + let off = crate::render_worker::Job { may_keep: false, ..pan_job(&doc, &key, (0.0, 0.0)) }; + render_job(&mut r, &mut kept, &off); + if kept.is_some() { + wrong.push("a frame that does not allow keeping"); + } + let whole = crate::render_worker::Job { may_copy: false, ..pan_job(&doc, &key, (0.0, 0.0)) }; + render_job(&mut r, &mut kept, &whole); + assert!(kept.is_some(), "a frame that may not copy is kept"); + let mut big = pan_job(&doc, &key, (0.0, 0.0)); + (big.key.w, big.key.h, big.w, big.h) = (4097, 4097, 4097, 4097); + render_job(&mut r, &mut kept, &big); + if kept.is_some() { + wrong.push("a frame over 64 MiB"); + } + assert!(wrong.is_empty(), "kept: {wrong:?}"); +} + +/// Through a pan, an edit, an undo, a zoom, a resize, a view mode and a color setting, each frame the +/// worker renders whole matches a fresh render, and each copied frame is within one level of it. +#[test] +fn worker_frames_match_a_fresh_render_through_a_session() { + let (doc, key) = pan_doc(); + let mut edited = (*doc).clone(); + edited.layers.reverse(); + let edited = std::sync::Arc::new(edited); + let pan = |at: (f64, f64)| pan_job(&doc, &key, at); + let changed = |f: &dyn Fn(&mut crate::render_worker::Job)| { + let mut job = pan((4.5, -4.0)); + f(&mut job); + job + }; + let steps: Vec<(&str, crate::render_worker::Job, bool)> = vec![ + ("still", pan((0.0, 0.0)), false), + ("pan", pan((10.0, 0.0)), true), + ("pan back", pan((3.0, -4.0)), true), + ("part of a pixel", pan((3.5, -4.0)), false), + ("whole again", pan((4.5, -4.0)), true), + ("settled", changed(&|j| j.may_copy = false), false), + ("pan after settling", pan((5.5, -4.0)), true), + ("back before the edit", pan((4.5, -4.0)), true), + ( + "edit", + changed(&|j| { + j.doc = edited.clone(); + j.key.revision += 1; + }), + false, + ), + ("undo", pan((4.5, -4.0)), false), + ( + "zoom", + changed(&|j| { + j.key.zoom = 2.0; + j.view = Affine::translate((4.5, -4.0)) * Affine::scale(2.0); + }), + false, + ), + ( + "resize", + changed(&|j| { + (j.key.w, j.key.h, j.w, j.h) = (200, 100, 200, 100); + }), + false, + ), + ( + "outline", + changed(&|j| { + j.key.outline = true; + j.opts.outline = true; + }), + false, + ), + ( + "rich black", + changed(&|j| { + j.key.rich_black = true; + j.opts.rich_black = true; + }), + false, + ), + ("setting back", pan((4.5, -4.0)), false), + ("first pan after the setting", pan((5.5, -4.0)), false), + ("next pan", pan((6.5, -4.0)), true), + ]; + let mut r = vectorcraft_render::Renderer::new(); + let mut kept = None; + for (name, job, copies) in steps { + let frame = render_job(&mut r, &mut kept, &job); + assert_eq!(frame.reused, copies, "{name}"); + let whole = vectorcraft_render::Renderer::new().render(&job.doc, job.w, job.h, job.view, &job.opts); + if copies { + let off = frame.img.pixels.iter().zip(&whole.pixels).map(|(a, b)| a.abs_diff(*b)).max(); + assert!(off.is_some_and(|d| d <= 1), "{name}: {off:?}"); + } else { + assert_eq!(frame.img.pixels, whole.pixels, "{name}"); + } + } +} + +/// A pan over art the frame's size decides stays within one level of a fresh render of the frame, +/// because it renders whole: a full-page raster effect whose raster a part of the frame would make +/// at another scale, and a group drawn offscreen across the exposed part's edge with an Inner Glow +/// child inside the part, whose filter layer starts at another pixel in the part. The worker does +/// not keep such a frame for the pan to copy from. +#[test] +fn a_pan_over_art_the_frame_size_decides_renders_whole() { + let (_, key) = pan_doc(); + let effect = |effect: &str, params: serde_json::Value| { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 595, "height": 842})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 595, "height": 842})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "color": "#1a4de6"})).unwrap(); + app.run("effect.apply", json!({"effect": effect, "ids": [id], "params": params})).unwrap(); + app.session.active().unwrap().doc.clone() + }; + let group_glow = { + use vectorcraft_doc::Appearance; + use vectorcraft_doc::color::{BlendMode, Color, Paint}; + use vectorcraft_geom::{Rect, shapes::rectangle}; + let mut d = vectorcraft_doc::Document::new(1600.0, 1200.0); + let basic = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let wide = Node::path(d.alloc_id(), rectangle(Rect::new(100.0, 100.0, 1590.0, 900.0)), basic(Color::rgb(0.9, 0.8, 0.3))); + let mut child = Node::path(d.alloc_id(), rectangle(Rect::new(1453.0, 200.0, 1583.0, 500.0)), basic(Color::rgb(0.1, 0.3, 0.9))); + child.appearance.effects = vec![vectorcraft_doc::Effect { id: "stylize.innerGlow".into(), params: json!({"blur": 40}), visible: true }]; + child.blend = BlendMode::Multiply; + let mut group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(wide), std::sync::Arc::new(child)]); + group.isolate = true; + let layer = d.layers[0].id; + d.insert(Some(layer), 0, group).unwrap(); + std::sync::Arc::new(d) + }; + let cases = [ + ("Gaussian Blur 500 pt", effect("blur.gaussian", json!({"radius": 500})), (-200.0, -200.0), 2.0, (1440, 870)), + ("Craquelure", effect("texture.craquelure", json!({})), (-200.0, -200.0), 10.0, (3840, 2160)), + ("Inner Glow in a group drawn offscreen", group_glow, (0.0, 0.0), 1.0, (1600, 1200)), + ]; + for (name, doc, (x, y), zoom, (w, h)) in cases { + let job = |dx: f64| { + let mut job = pan_job(&doc, &key, (dx, 0.0)); + (job.key.w, job.key.h, job.w, job.h, job.key.zoom) = (w, h, w, h, zoom); + job.view = Affine::translate((x + dx, y)) * Affine::scale(zoom); + job + }; + let mut r = vectorcraft_render::Renderer::new(); + let mut kept = None; + render_job(&mut r, &mut kept, &job(0.0)); + let pan = job(-40.0); + let frame = render_job(&mut r, &mut kept, &pan); + let whole = vectorcraft_render::Renderer::new().render(&pan.doc, pan.w, pan.h, pan.view, &pan.opts); + let off = frame.img.pixels.iter().zip(&whole.pixels).map(|(a, b)| a.abs_diff(*b)).max(); + assert!(off.is_some_and(|d| d <= 1), "{name}: a channel differs by {off:?}"); + assert!(!frame.reused, "{name}: the pan renders whole"); + assert!(kept.is_none(), "{name}: the frame is not kept"); + } +} + +/// Runs `test`, whose canvas key holds the global placed-document bitmap clock, which tests on +/// other threads move: a frame whose key changed by more than the view renders whole. An attempt +/// that fails while the clock moved runs again; one that fails while it stood still is a real +/// failure. +fn while_the_placed_clock_stands_still(test: fn()) { + for _ in 0..20 { + let clock = vectorcraft_render::placed_document::generation(); + match std::panic::catch_unwind(test) { + Ok(()) => return, + Err(_) if vectorcraft_render::placed_document::generation() != clock => {} + Err(e) => std::panic::resume_unwind(e), + } + } + test(); +} + +/// The canvas asks the worker for a frame that may copy pixels when the view moves, keeps the whole +/// frame's time, and renders the frame whole once the view has been still for STILL_SECS, not +/// before and only once. +#[test] +fn a_frame_with_copied_pixels_renders_whole_once_the_view_stops() { + while_the_placed_clock_stands_still(frame_with_copied_pixels_renders_whole_once_the_view_stops); +} + +fn frame_with_copied_pixels_renders_whole_once_the_view_stops() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; + let (worker, jobs, done_tx) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp, t: f64| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(t), ..Default::default() }; + ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear(); + }; + frame(&mut app, 1.0); + done_tx.send(finished(&jobs.try_recv().expect("the first frame"))).unwrap(); + frame(&mut app, 1.0); + assert_eq!(app.canvas.last_ms, 40.0); + assert!(app.canvas.reused.is_none()); + + app.view_mut().unwrap().center.x += 5.0; + frame(&mut app, 2.0); + let pan = jobs.try_recv().expect("the pan's frame goes to the worker"); + assert!(pan.may_copy, "a new view may copy"); + done_tx.send(crate::render_worker::Done { ms: 2.0, reused: true, ..finished(&pan) }).unwrap(); + frame(&mut app, 3.0); + assert_eq!(app.canvas.reused, Some(3.0)); + assert_eq!(app.canvas.last_ms, 40.0, "a frame with copied pixels keeps the whole frame's time"); + assert_eq!(app.perf.render_ms, 2.0); + + frame(&mut app, 3.0 + STILL_SECS / 2.0); + assert!(jobs.try_recv().is_err(), "the view has not been still long enough"); + frame(&mut app, 3.0 + STILL_SECS); + let settle = jobs.try_recv().expect("the frame renders whole"); + assert!(!settle.may_copy, "the settling frame copies nothing"); + assert_eq!(settle.key, pan.key); + frame(&mut app, 3.0 + STILL_SECS * 2.0); + assert!(jobs.try_recv().is_err(), "queued once"); + done_tx.send(finished(&settle)).unwrap(); + frame(&mut app, 4.0); + assert!(app.canvas.reused.is_none()); + frame(&mut app, 5.0); + assert!(jobs.try_recv().is_err(), "nothing to render after it"); +} + +/// The frame that renders whole after a pan stops copies nothing and stays on the worker, so the +/// first step of the next pan copies from it. The jobs are the ones the canvas asks for. +#[test] +fn a_pan_after_the_settling_frame_copies() { + while_the_placed_clock_stands_still(pan_after_the_settling_frame_copies); +} + +fn pan_after_the_settling_frame_copies() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; + let (worker, jobs, done_tx) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp, t: f64| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(t), ..Default::default() }; + ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear(); + }; + let mut r = vectorcraft_render::Renderer::new(); + let mut kept = None; + let mut render = |name: &str, copies: bool| { + let job = jobs.try_recv().unwrap_or_else(|_| panic!("{name}: the canvas asks for a frame")); + let f = render_job(&mut r, &mut kept, &job); + assert_eq!(f.reused, copies, "{name}"); + let done = crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img: f.img, ms: 40.0, moved: f.moved, reused: f.reused }; + done_tx.send(done).unwrap(); + }; + frame(&mut app, 1.0); + render("the first frame", false); + frame(&mut app, 1.0); + // At 100% the view moves by whole device pixels. + app.view_mut().unwrap().zoom = 1.0; + frame(&mut app, 1.5); + render("the zoom", false); + frame(&mut app, 1.5); + + app.view_mut().unwrap().center.x += 5.0; + frame(&mut app, 1.75); + render("the first pan after the zoom", false); + frame(&mut app, 1.75); + + app.view_mut().unwrap().center.x += 5.0; + frame(&mut app, 2.0); + render("the pan", true); + frame(&mut app, 3.0); + frame(&mut app, 3.0 + STILL_SECS); + render("the settling frame", false); + frame(&mut app, 4.0); + + app.view_mut().unwrap().center.x += 5.0; + frame(&mut app, 5.0); + render("the next pan", true); +} + +/// A frame after a zoom or an edit holds no pixels, and the frame before it lets its pixels go. The +/// first pan step after it renders whole and holds its pixels, and the next step copies them. +#[test] +fn a_frame_after_a_zoom_or_an_edit_holds_no_pixels() { + let (doc, key) = pan_doc(); + let mut edited = (*doc).clone(); + edited.layers.reverse(); + let edited = std::sync::Arc::new(edited); + let zoomed = |key: &CacheKey, at: f64, zoom: f64| { + let mut job = pan_job(&doc, key, (at, 0.0)); + job.key.zoom = zoom; + job.view = Affine::translate((at, 0.0)) * Affine::scale(zoom); + job + }; + let revised = CacheKey { revision: key.revision + 1, ..key.clone() }; + let steps: Vec<(&str, crate::render_worker::Job, bool, bool)> = vec![ + ("the first frame", pan_job(&doc, &key, (0.0, 0.0)), false, true), + ("a zoom", zoomed(&key, 0.0, 2.0), false, false), + ("another zoom", zoomed(&key, 0.0, 1.5), false, false), + ("the first pan after the zoom", zoomed(&key, 10.0, 1.5), false, true), + ("the next pan", zoomed(&key, 20.0, 1.5), true, true), + ("an edit", crate::render_worker::Job { doc: edited.clone(), ..pan_job(&doc, &revised, (0.0, 0.0)) }, false, false), + ("the first pan after the edit", crate::render_worker::Job { doc: edited.clone(), ..pan_job(&doc, &revised, (10.0, 0.0)) }, false, true), + ("the next pan after the edit", crate::render_worker::Job { doc: edited.clone(), ..pan_job(&doc, &revised, (20.0, 0.0)) }, true, true), + ]; + let mut r = vectorcraft_render::Renderer::new(); + let mut kept = None; + for (name, job, copies, holds) in steps { + assert_eq!(render_job(&mut r, &mut kept, &job).reused, copies, "{name}: copied"); + assert_eq!(kept.as_ref().is_some_and(|k| k.pixels.is_some()), holds, "{name}: holds its pixels"); + } +} + +/// The worker thread itself copies pixels on a pan and reports it. +#[test] +fn the_render_worker_thread_copies_a_pan() { + let (doc, key) = pan_doc(); + let mut worker = crate::render_worker::Worker::spawn(egui::Context::default()).expect("a worker"); + // Blocks until the worker sends its frame. A worker thread that stopped gives None. + let wait = |worker: &mut crate::render_worker::Worker| worker.wait(std::time::Duration::MAX).expect("the worker sends the frame"); + worker.submit(pan_job(&doc, &key, (0.0, 0.0))); + assert!(!wait(&mut worker).reused); + worker.submit(pan_job(&doc, &key, (10.0, 0.0))); + assert!(wait(&mut worker).reused); } /// #4155: a document's first frame renders on the render worker, not on the UI thread, where a diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 41d73ffae..ae8fbccc0 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -20,7 +20,7 @@ mod tabs; #[cfg(test)] use control_bar::ISOLATION_BUTTON; -pub use control_bar::{AnchorControls, anchor_buttons, anchor_controls, control_bar}; +pub use control_bar::{AnchorControls, CONTROL_BAR_EDGES, anchor_buttons, anchor_controls, control_bar}; #[cfg(test)] use hints::hint_segments; pub use hints::{hint_bar, hint_strings}; diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 70c6fe272..5374305e0 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -261,19 +261,83 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize true } -/// The Control bar (Window → Control), context-sensitive like Illustrator's. +/// The edges of the window the Control bar docks to (`window.control.dock {edge}`): the top, then +/// the bottom. +pub const CONTROL_BAR_EDGES: [&str; 2] = ["top", "bottom"]; + +/// The side of the Control bar's panel menu button, at the bar's right end. +const MENU_BUTTON: f32 = 16.0; + +/// The height of the band drawn along the edge of the window that a gripper drag docks the bar to. +const DOCK_BAND: f32 = 4.0; + +/// Docks the Control bar to `edge` of [`CONTROL_BAR_EDGES`], through its command. +fn dock(app: &mut VectorcraftApp, edge: &str) { + crate::menus::invoke(app, "window.control.dock", json!({ "edge": edge })); +} + +/// The Control bar's panel menu: Dock to Top and Dock to Bottom, the edge the bar is docked to +/// checked. +fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { + let resp = crate::panels::menu_button(ui, "control", rect); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(160.0); + for (i, label) in [tl!("Dock to Top"), tl!("Dock to Bottom")].into_iter().enumerate() { + if widgets::menu_item(ui, label, true, usize::from(app.ui.control_bar_bottom) == i) { + dock(app, CONTROL_BAR_EDGES[i]); + } + } + }); +} + +/// The Control bar's gripper. Dragging it shows a band along the top or the bottom edge of the +/// window, whichever the pointer is nearer, and docks the bar there on release. +fn gripper(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { + let (g, resp) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::drag()); + for i in 0..5 { + ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); + } + let resp = resp.on_hover_cursor(egui::CursorIcon::Grab); + if !resp.dragged() && !resp.drag_stopped() { + return; + } + let Some(pos) = resp.interact_pointer_pos().or_else(|| ui.ctx().pointer_latest_pos()) else { return }; + let window = ui.ctx().content_rect(); + let bottom = pos.y > window.center().y; + if resp.drag_stopped() { + if bottom != app.ui.control_bar_bottom { + dock(app, CONTROL_BAR_EDGES[usize::from(bottom)]); + } + return; + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let band = if bottom { + egui::Rect::from_min_max(egui::pos2(window.left(), window.bottom() - DOCK_BAND), window.max) + } else { + egui::Rect::from_min_max(window.min, egui::pos2(window.right(), window.top() + DOCK_BAND)) + }; + let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("control_bar_dock_band")); + ui.ctx().layer_painter(layer).rect_filled(band, 0.0, t.accent); +} + +/// The Control bar (Window → Control), context-sensitive like Illustrator's, docked to the top or +/// the bottom of the window. pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::top("control_bar") + let bottom = app.ui.control_bar_bottom; + let panel = if bottom { egui::Panel::bottom("control_bar") } else { egui::Panel::top("control_bar") }; + let bar = panel .exact_size(34.0) .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))) .show(ui, |ui| { - ui.horizontal_centered(|ui| { - // Gripper - let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover()); - for i in 0..5 { - ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); - } + // The panel menu button takes the right end, and the controls trim to the room left of it: + // they are laid out in that room, so every room check below leaves the button's width. + let full = ui.max_rect(); + let menu = egui::Rect::from_center_size(egui::pos2(full.right() - MENU_BUTTON / 2.0, full.center().y), vec2(MENU_BUTTON, MENU_BUTTON)); + let room = egui::Rect::from_min_max(full.min, egui::pos2(menu.left() - ui.spacing().item_spacing.x, full.bottom())); + let layout = egui::Layout::left_to_right(egui::Align::Center); + let controls = ui.scope_builder(egui::UiBuilder::new().max_rect(room).layout(layout), |ui| { + gripper(app, ui, &t); if app.session.active().is_none() { ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim)); return; @@ -405,5 +469,14 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); + // Controls that keep their size can still run into the button's room in a narrow window; + // the button is left out then. + if controls.response.rect.right() <= menu.left() { + panel_menu(app, ui, menu); + } }); + // Docked at the bottom, the bar is a bottom bar that menus stop above. + if bottom { + widgets::set_menu_floor(ui.ctx(), bar.response.rect.top()); + } } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 86a85699d..00d790964 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -1801,6 +1801,139 @@ fn full_screen_modes_fill_the_screen_and_normal_mode_restores_the_window() { assert!(sent(&mut app, true).is_empty(), "still full screen, as the user had it"); } +/// The window the Control bar docking tests draw the bar in. +const DOCK_SCREEN: egui::Rect = egui::Rect { min: egui::Pos2::ZERO, max: egui::pos2(1200.0, 300.0) }; + +/// One headless frame of the Control bar alone; returns its accessibility nodes. +fn control_bar_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(DOCK_SCREEN), ..Default::default() }, |ui| super::control_bar(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 bounds of the node whose label, without a check mark, is `name`. +fn node_center(nodes: &[egui::accesskit::Node], name: &str) -> Option { + let n = nodes.iter().find(|n| n.label().is_some_and(|l| l.trim_start_matches('✓').trim() == name))?; + let b = n.bounds()?; + Some(egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) +} + +/// A primary-button press or release at `pos`. +fn press_at(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } +} + +/// The Control bar's panel menu has Dock to Top and Dock to Bottom, the edge in use checked, and +/// each docks the bar to that edge of the window. +#[test] +fn the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let pick = |app: &mut crate::VectorcraftApp, item: &str| { + control_bar_frame(app, &ctx, vec![]); + let menu = node_center(&control_bar_frame(app, &ctx, vec![]), "Panel menu").unwrap_or_else(|| panic!("the bar has a panel menu")); + control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(menu), press_at(menu, true)]); + control_bar_frame(app, &ctx, vec![press_at(menu, false)]); + let nodes = control_bar_frame(app, &ctx, vec![]); + let checked: Vec = nodes.iter().filter_map(|n| n.label()).filter(|l| l.starts_with('✓')).map(str::to_string).collect(); + let at = node_center(&nodes, item).unwrap_or_else(|| panic!("the menu lists {item}")); + // Below the button at the top, and above it at the bottom: the menu opens inside the window. + assert!(DOCK_SCREEN.contains(at) && (at.y < menu.y) == app.ui.control_bar_bottom, "{item} at {at:?}, the button at {menu:?}"); + control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(at), press_at(at, true)]); + control_bar_frame(app, &ctx, vec![press_at(at, false)]); + control_bar_frame(app, &ctx, vec![]); + let menu = node_center(&control_bar_frame(app, &ctx, vec![]), "Panel menu").unwrap_or_else(|| panic!("the bar still shows")); + (checked, menu.y) + }; + let (checked, y) = pick(&mut app, "Dock to Bottom"); + assert_eq!(checked, ["✓ Dock to Top"], "the bar starts docked at the top"); + assert!(app.ui.control_bar_bottom, "Dock to Bottom docks it at the bottom"); + assert!(y > DOCK_SCREEN.bottom() - 34.0, "the bar is drawn along the bottom of the window: {y}"); + let (checked, y) = pick(&mut app, "Dock to Top"); + assert_eq!(checked, ["✓ Dock to Bottom"]); + assert!(!app.ui.control_bar_bottom && y < 34.0, "Dock to Top docks it at the top again: {y}"); +} + +/// Dragging the Control bar's gripper docks the bar at the edge of the window the pointer is +/// released nearer; a drag released nearer the edge it is at leaves it there. +#[test] +fn dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // The gripper is the bar's first item, inside its 10-point margin, centered in the 34-point bar. + let grip = |bottom: bool| egui::pos2(13.0, if bottom { DOCK_SCREEN.bottom() - 17.0 } else { 17.0 }); + let drag = |app: &mut crate::VectorcraftApp, from: egui::Pos2, to: egui::Pos2| { + control_bar_frame(app, &ctx, vec![]); + control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(from), press_at(from, true)]); + for k in 1..=4 { + control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(from.lerp(to, k as f32 / 4.0))]); + } + control_bar_frame(app, &ctx, vec![press_at(to, false)]); + }; + drag(&mut app, grip(false), egui::pos2(400.0, 200.0)); + assert!(app.ui.control_bar_bottom, "released nearer the bottom: docked at the bottom"); + drag(&mut app, grip(true), egui::pos2(400.0, 160.0)); + assert!(app.ui.control_bar_bottom, "released nearer the bottom again: still at the bottom"); + drag(&mut app, grip(true), egui::pos2(400.0, 120.0)); + assert!(!app.ui.control_bar_bottom, "released nearer the top: docked at the top"); + drag(&mut app, grip(false), egui::pos2(400.0, 140.0)); + assert!(!app.ui.control_bar_bottom, "released nearer the top again: still at the top"); +} + +/// `window.control.dock {edge}` docks the Control bar to the top or the bottom of the window, and +/// the panel menu checks the edge in use; another edge, or none, is an error and changes nothing. +#[test] +fn the_control_bar_dock_command_takes_top_or_bottom() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + assert_eq!(app.run("window.control.dock", json!({"edge": "bottom"})).unwrap(), json!({"edge": "bottom"})); + assert!(app.ui.control_bar_bottom); + assert_eq!(crate::menus::checked(&app, "window.control.dock", &json!({"edge": "bottom"})), Some(true)); + assert_eq!(crate::menus::checked(&app, "window.control.dock", &json!({"edge": "top"})), Some(false)); + for p in [json!({"edge": "left"}), json!({"edge": "Bottom"}), json!({"edge": 1}), json!({})] { + let err = app.run("window.control.dock", p.clone()).unwrap_err(); + assert_eq!(err, "`edge` must be one of top, bottom", "{p}"); + assert!(app.ui.control_bar_bottom, "{p} leaves the bar at the bottom"); + } + assert_eq!(app.run("window.control.dock", json!({"edge": "top"})).unwrap(), json!({"edge": "top"})); + assert!(!app.ui.control_bar_bottom); +} + +/// Menus stop above a Control bar docked at the bottom when the status bar is hidden, above the +/// status and hint bars when they show, and at the bottom of the window when the bar is docked at +/// the top or a screen mode hides it. +#[test] +fn menus_stop_above_a_control_bar_docked_at_the_bottom() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let floor = |app: &mut crate::VectorcraftApp, status: bool, bottom: bool, screen_mode: u8| { + (app.ui.status_bar, app.ui.control_bar, app.ui.control_bar_bottom, app.ui.screen_mode) = (status, true, bottom, screen_mode); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + crate::widgets::menu_floor(&ctx) + }; + let bar_top = screen.bottom() - 34.0; + let f = floor(&mut app, false, true, 0); + assert!((f - bar_top).abs() < 1.0, "status bar hidden: menus stop at the Control bar's top, {f}"); + let f = floor(&mut app, false, true, 2); + assert_eq!(f, screen.bottom(), "screen mode 2 hides the Control bar: menus stop at the window's bottom"); + let f = floor(&mut app, false, true, 0); + assert!((f - bar_top).abs() < 1.0, "shown again: {f}"); + let f = floor(&mut app, false, false, 0); + assert_eq!(f, screen.bottom(), "docked at the top: menus stop at the window's bottom"); + let f = floor(&mut app, true, true, 0); + assert!(f < bar_top - 1.0, "the status and hint bars show above the Control bar: menus stop above them, {f}"); +} + /// Two or more selected objects show all six Distribute Objects buttons in the Control bar; /// a single object keeps the bar as it was. A click distributes as the Align panel's button does. #[test] @@ -1955,6 +2088,7 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() { ctx.enable_accesskit(); crate::theme::install_fonts(&ctx); let mut seen = false; + let mut menu_seen = false; // The Align group, then for two objects the Distribute group (#5372): each by its controls. let mut groups: Vec<(&str, Vec<&str>)> = vec![( "Align", @@ -2002,16 +2136,22 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() { let fields = ["X:", "Y:", "W:", "H:"].map(right); assert!(fields.iter().all(Option::is_some) || fields.iter().all(Option::is_none), "{count} objects, {width}: {fields:?}"); assert!(nodes.iter().any(|(_, n)| n.label() == Some("Transform")), "{count} objects, {width}: the Transform link shows"); - // The bar's content ends at its 10-point inner margin. + // The panel menu button takes the bar's right end, and the bar's content ends before it + // (without the button, at the bar's 10-point inner margin). Once a bar shows the button, + // a wider one keeps it: the fields and groups trim to the room left of it. + let menu = nodes.iter().find(|(_, n)| n.label() == Some("Panel menu")).and_then(|(_, n)| n.bounds()); + assert!(!menu_seen || menu.is_some(), "{count} objects, {width}: the panel menu button left out after a narrower bar showed it"); + menu_seen |= menu.is_some(); + let edge = menu.map_or(f64::from(width) - 10.0, |m| m.x0); if let Some(h) = fields[3] { - assert!(h <= f64::from(width) - 10.0, "{count} objects, {width}: H ends at {h}"); + assert!(h <= edge, "{count} objects, {width}: H ends at {h}, the content's room at {edge}"); } assert!(!seen || fields[3].is_some(), "{count} objects, {width}: left out after a narrower bar showed them"); seen |= fields[3].is_some(); // The Align and Distribute buttons show whole inside the margin, with the Transform // link after them, or are left out; once shown, a wider bar keeps them. let end = |name: &str| nodes.iter().find(|(_, n)| n.label() == Some(name)).and_then(|(_, n)| n.bounds()).map(|b| b.x1); - let inside = |x: f64| x <= f64::from(width) - 10.0; + let inside = |x: f64| x <= edge; for ((group, names), was) in groups.iter().zip(groups_seen.iter_mut()) { let ends: Vec> = names.iter().map(|n| end(n)).collect(); let shown = ends.iter().all(Option::is_some); @@ -2026,6 +2166,7 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() { } } assert!(seen, "{count} objects: a wide bar has room for the fields"); + assert!(menu_seen, "{count} objects: a wide bar has room for the panel menu button"); assert!(groups_seen.iter().all(|s| *s), "{count} objects: a wide bar shows every group: {groups_seen:?}"); } } diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index fa2be21c0..adbf1223f 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -343,7 +343,10 @@ pub(crate) fn show_dialog(app: &mut VectorcraftApp, ctx: &egui::Context) { } }); ui.add_space(16.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // As wide as the field and as tall as the buttons, as in `dialogs::show`: a right-to-left + // layout would take all the height left in the window and center the buttons in it. + 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(ui, tl!("OK")).clicked() { ok = true; } @@ -445,7 +448,8 @@ pub(crate) fn show_manage(app: &mut VectorcraftApp, ctx: &egui::Context) { } }); ui.add_space(16.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + 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(ui, tl!("OK")).clicked() { ok = true; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 43e274505..a7766dd8d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -11,6 +11,7 @@ pub mod action_batch; pub mod action_new; pub mod action_playback; pub mod action_set_options; +pub mod action_stop; pub mod artboard_options; pub mod blend_options; mod brush_art_options; @@ -91,8 +92,9 @@ pub mod raster_effects; pub mod rearrange_artboards; pub mod recolor; mod recovery; +pub mod relink; pub mod rename_artboards; -mod revolve; +pub(crate) mod revolve; pub(crate) mod revolve_gizmo; pub mod saturate; pub mod save_brush_library; @@ -367,11 +369,14 @@ registry! { OfficeExport: [office_export::KIND] => office_export::SPEC, DxfOptions: [dxf_options::KIND] => dxf_options::SPEC, PlacementOptions: [placement_options::KIND] => placement_options::SPEC, + Relink: [relink::KIND] => relink::SPEC, Package: [package::KIND] => package::SPEC, ActionBatch: [action_batch::KIND] => action_batch::SPEC, ActionSetOptions: [action_set_options::KIND] => action_set_options::SPEC, ActionNew: [action_new::KIND] => action_new::SPEC, ActionPlayback: [action_playback::KIND] => action_playback::SPEC, + ActionStopRecord: [action_stop::RECORD_KIND] => action_stop::RECORD_SPEC, + ActionStopMessage: [action_stop::MESSAGE_KIND] => action_stop::MESSAGE_SPEC, SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC, DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, EpsOptions: [eps_options::KIND] => eps_options::SPEC, diff --git a/crates/ui-egui/src/dialogs/action_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs index 3d8bfd4ac..0d6382e9a 100644 --- a/crates/ui-egui/src/dialogs/action_batch.rs +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -23,6 +23,9 @@ pub fn open(app: &mut VectorcraftApp, set: usize, action: usize) -> Result Result { if commands.is_empty() { return Err("the action has no included steps".into()); } + if crate::panels::actions::has_stop(&commands) { + return Err("Batch cannot play an action with a stop".into()); + } let source = d.str("source"); if source == "folder" && d.str("folder").trim().is_empty() { return Err("choose a source folder".into()); @@ -112,7 +118,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { return Err("open a document with data sets".into()); } let params = params(d, &a.name, commands); - let r = app.run("action.batch", params)?; + let r = app.replay("action.batch", params)?; app.ui.dialog = None; let count = r["count"].as_u64().unwrap_or(0); let failed = r["failed"].as_u64().unwrap_or(0); @@ -183,6 +189,25 @@ mod tests { assert!(!shown("folder").iter().any(|label| label == "File Name:")); } + /// Batch plays an action recorded with a key its command no longer reads, as Play does. + #[test] + fn an_action_with_a_key_no_longer_read_still_plays_in_a_batch() { + use crate::panels::actions::{Action, ActionSet}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("dataset.new", json!({"name": "a"})).unwrap(); + let old = json!({"x": 10, "y": 10, "width": 20, "height": 20, "cornerStyle": "round"}); + assert!(app.run("shape.rectangle", old.clone()).is_err()); + app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Draw", vec![("shape.rectangle".into(), old)])])]; + let mut d = Dialog::new(KIND, initial_fields(0, 0, "Draw")); + d.fields["source"] = json!("datasets"); + d.fields["destination"] = json!("none"); + let r = confirm(&mut app, &d).unwrap(); + assert_eq!((r["count"].clone(), r["failed"].clone()), (json!(1), json!(0)), "{r}"); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 1); + assert!(!app.session.replaying()); + } + #[test] fn data_set_naming_params_follow_dialog_choice() { let mut d = Dialog::new(KIND, initial_fields(0, 0, "Test")); diff --git a/crates/ui-egui/src/dialogs/action_stop.rs b/crates/ui-egui/src/dialogs/action_stop.rs new file mode 100644 index 000000000..ccf5096a2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/action_stop.rs @@ -0,0 +1,107 @@ +//! Actions panel menu › Insert Stop… (`ui.action.insertStop`) and the message box a playing action +//! shows at a stop. +//! +//! Record Stop's fields: `message`, `allowContinue`, and `set`, `action` and `step`, the place of the +//! new stop (no `set` and `action`: the recording in progress). An invalid place is an error, and +//! the dialog stays open. +//! +//! The message box's fields: `action` (the action's name, shown as the heading), `message` and +//! `allowContinue`. Stop or Cancel closes it, and the playback keeps waiting for Play Current +//! Selection or Stop Playing/Recording. With `allowContinue`, Continue or `ui.dialog.confirm` plays +//! on; without it, `ui.dialog.confirm` presses Stop. + +use serde_json::{Value, json}; + +use super::{DialogAction, DialogSpec}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Record Stop. +pub const RECORD_KIND: &str = "actionStopRecord"; + +/// The dialog kind of a stop's message box. +pub const MESSAGE_KIND: &str = "actionStopMessage"; + +/// The width of Record Stop's label column. +const LABEL_W: f32 = 70.0; + +pub(super) const RECORD_SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Record Stop").into(), body: record_body, confirm: record_confirm, min_width: 340.0, ..DialogSpec::FORM }; + +pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec { + heading: |d| d.str("action"), + body_with_actions: Some(message_body), + confirm: message_confirm, + ok: None, + min_width: 300.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; + +/// Opens Record Stop for action `target` as (set, action), after `step`, else for the recording in +/// progress. +pub fn open_record(app: &mut VectorcraftApp, target: Option<(usize, usize)>, step: Option) { + let mut fields = json!({"message": "", "allowContinue": false}); + if let Some((set, action)) = target { + fields["set"] = json!(set); + fields["action"] = json!(action); + if let Some(step) = step { + fields["step"] = json!(step); + } + } + app.ui.dialog = Some(Dialog::new(RECORD_KIND, fields)); +} + +fn record_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + widgets::label_row(ui, "Message:", LABEL_W, |ui| { + super::form::text(ui, d, "message", 240.0); + }); + super::form::check(ui, d, "allowContinue", "Allow Continue"); + false +} + +fn record_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut params = json!({"message": d.str("message"), "allowContinue": d.bool("allowContinue")}); + for key in ["set", "action", "step"] { + if let Some(value) = d.fields.get(key) { + params[key] = value.clone(); + } + } + let r = app.run(crate::panels::actions::INSERT_STOP, params)?; + app.ui.dialog = None; + Ok(r) +} + +/// Shows the message box of a stop in the action named `action`. +pub(crate) fn open_message(app: &mut VectorcraftApp, action: &str, message: &str, allow_continue: bool) { + app.ui.dialog = Some(Dialog::new(MESSAGE_KIND, json!({"action": action, "message": message, "allowContinue": allow_continue}))); +} + +fn message_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction { + let message = d.str("message"); + if !message.is_empty() { + ui.label(message); + ui.add_space(8.0); + } + ui.label(egui::RichText::new(tl!("Press Play Current Selection in the Actions panel to go on.")).color(Tokens::get(ui.ctx()).text_dim)); + ui.add_space(16.0); + let mut action = DialogAction::None; + ui.horizontal(|ui| { + if d.bool("allowContinue") && widgets::primary_button_enabled(ui, tl!("Continue"), true).clicked() { + action = DialogAction::Confirm; + } + if widgets::secondary_button(ui, tl!("Stop")).clicked() { + action = DialogAction::Cancel; + } + }); + action +} + +fn message_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.bool("allowContinue") { + return app.run(crate::panels::actions::CONTINUE_PLAYBACK, json!({})); + } + app.ui.dialog = None; + Ok(Value::Null) +} diff --git a/crates/ui-egui/src/dialogs/artboard_options.rs b/crates/ui-egui/src/dialogs/artboard_options.rs index 94ec54317..0ce0de935 100644 --- a/crates/ui-egui/src/dialogs/artboard_options.rs +++ b/crates/ui-egui/src/dialogs/artboard_options.rs @@ -7,6 +7,9 @@ //! and its video display options, Show Video Safe Areas and Video Ruler Pixel Aspect Ratio //! (`showVideoSafeAreas`, `videoRulerPixelAspectRatio`) //! (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html). +//! Under Global, Fade Region Outside Artboard and Update While Dragging set the application +//! preferences `fadeRegionOutsideArtboard` and `artboardUpdateWhileDragging` for every document +//! (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, Artboard options). //! Sizing choices also go to the other selected artboards; fitting changes only the active //! artboard around stationary artwork. @@ -28,8 +31,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { body, confirm, // A new artboard's dialog opens without these; its swatch, checkboxes and Video Ruler Pixel - // Aspect Ratio field add them. - inputs: |_| &["color", "showCenterMark", "showCrossHairs", "showVideoSafeAreas", PIXEL_ASPECT], + // Aspect Ratio field add them. The Global checkboxes' fields come from the preferences. + inputs: |_| &["color", "showCenterMark", "showCrossHairs", "showVideoSafeAreas", PIXEL_ASPECT, FADE, UPDATE_WHILE_DRAGGING], ..DialogSpec::FORM }; @@ -47,7 +50,8 @@ fn placed(d: &Dialog) -> Rect { /// reference point where it was, as their sizes do. /// Then Background Color: the Fill list (None, White, Black; Custom for another color) and a swatch /// that picks a color of its own, the Show Center Mark, Show Cross Hairs and Show Video Safe Areas -/// checkboxes, and the Video Ruler Pixel Aspect Ratio field. +/// checkboxes, and the Video Ruler Pixel Aspect Ratio field. Last, under Global, the Fade Region +/// Outside Artboard and Update While Dragging checkboxes. fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (unit, origin, refi) = (app.session.general_unit(), app.ruler_origin(), reference_index(ui)); let (was, w0, h0) = (placed(d), d.f64("width", 0.0), d.f64("height", 0.0)); @@ -126,9 +130,59 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); }); + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Global")).strong()); + form::check(ui, d, FADE, tl!("Fade Region Outside Artboard")); + form::check(ui, d, UPDATE_WHILE_DRAGGING, tl!("Update While Dragging")); false } +/// The preference and field of Fade Region Outside Artboard: while the Artboard tool is active, +/// the canvas draws everything outside the artboards darker. +const FADE: &str = "fadeRegionOutsideArtboard"; +/// The preference and field of Update While Dragging: the area outside the artboards stays darker +/// while the Artboard tool moves or resizes an artboard. +const UPDATE_WHILE_DRAGGING: &str = "artboardUpdateWhileDragging"; + +/// `fields` with the Global checkboxes ([`global_fields`]) added. +fn with_globals(app: &VectorcraftApp, mut fields: Value) -> Value { + for (key, on) in global_fields(app) { + fields[key] = json!(on); + } + fields +} + +/// The Global checkboxes as the preferences have them, for the dialog's fields. +fn global_fields(app: &VectorcraftApp) -> [(&'static str, bool); 2] { + let prefs = &app.session.prefs; + [(FADE, prefs.fade_region_outside_artboard), (UPDATE_WHILE_DRAGGING, prefs.artboard_update_while_dragging)] +} + +/// The Global checkboxes' preferences that the form changed. A value that is not true or false is +/// an error, which keeps the dialog open before anything changes. +fn global_changes(app: &VectorcraftApp, d: &Dialog) -> Result, String> { + let mut values = serde_json::Map::new(); + for (key, now) in global_fields(app) { + match d.fields.get(key) { + Some(Value::Bool(on)) if *on != now => { + values.insert(key.into(), json!(on)); + } + Some(Value::Bool(_)) | None => {} + Some(_) => return Err(format!("`{key}` must be true or false")), + } + } + Ok(values) +} + +/// OK sets the Global checkboxes' preferences that the form changed ([`global_changes`]), in one +/// `prefs.set`, and leaves the preferences alone when none changed. +fn set_globals(app: &mut VectorcraftApp, values: serde_json::Map) -> Result<(), String> { + if values.is_empty() { + return Ok(()); + } + app.run("prefs.set", json!({ "values": values })).map(drop) +} + /// The field of the Video Ruler Pixel Aspect Ratio, as `artboard.setProps` takes it. pub(super) const PIXEL_ASPECT: &str = "videoRulerPixelAspectRatio"; @@ -243,7 +297,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { "showCenterMark": a.show_center_mark, "showCrossHairs": a.show_cross_hairs, "showVideoSafeAreas": a.show_video_safe_areas, PIXEL_ASPECT: a.video_ruler_pixel_aspect_ratio, }); - app.ui.dialog = Some(Dialog::new(KIND, fields)); + app.ui.dialog = Some(Dialog::new(KIND, with_globals(app, fields))); Ok(json!({ "dialog": KIND })) } @@ -252,10 +306,14 @@ pub fn open(app: &mut VectorcraftApp) -> Result { /// one is among them), each resized about the reference /// point the form's last Width or Height change used (fields `__refx` and `__refy`, its column and /// row; the top-left corner when the form set none), all in one undo step. Fitting applies only -/// to this artboard and never moves its artwork. +/// to this artboard and never moves its artwork. The Global checkboxes' preferences are set only +/// after the artboard command succeeds, so an error leaves them as they were. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let globals = global_changes(app, d)?; if d.bool("new") { - return confirm_new(app, d); + let r = confirm_new(app, d)?; + set_globals(app, globals)?; + return Ok(r); } let mut p = Value::Object(d.fields.clone()); for k in ["x", "y", "width", "height", PIXEL_ASPECT] { @@ -264,7 +322,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } } if let Some(o) = p.as_object_mut() { - o.retain(|k, _| !k.starts_with("__")); + o.retain(|k, _| !k.starts_with("__") && k != FADE && k != UPDATE_WHILE_DRAGGING); } let index = p["index"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(0); let was = app.session.active().and_then(|st| st.doc.artboards.get(index)).map(|a| a.rect); @@ -295,7 +353,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { "moveArt": !d.bool("__fit") && crate::panels::artboards::move_art(app), }); let p = crate::panels::artboards::set_props_params(common, entries).ok_or("no artboard")?; - run_and_close(app, "artboard.setProps", p) + let r = run_and_close(app, "artboard.setProps", p)?; + set_globals(app, globals)?; + Ok(r) } /// `ui.newArtboard` (Alt-click on the Artboards panel's New Artboard): Artboard Options for a new @@ -309,7 +369,7 @@ pub fn open_new(app: &mut VectorcraftApp) -> Result { "new": true, "name": format!("Artboard {}", abs.len() + 1), "x": last.x1 + 20.0, "y": last.y0, "width": last.width(), "height": last.height(), }); - app.ui.dialog = Some(Dialog::new(KIND, fields)); + app.ui.dialog = Some(Dialog::new(KIND, with_globals(app, fields))); Ok(json!({ "dialog": KIND })) } diff --git a/crates/ui-egui/src/dialogs/objects_on_path_options.rs b/crates/ui-egui/src/dialogs/objects_on_path_options.rs index 06aae7f8e..8a02cd37c 100644 --- a/crates/ui-egui/src/dialogs/objects_on_path_options.rs +++ b/crates/ui-egui/src/dialogs/objects_on_path_options.rs @@ -48,7 +48,7 @@ fn reset(_: &VectorcraftApp, d: &mut Dialog) { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { widgets::label_row(ui, tl!("Pivot:"), LABEL_W, |ui| { let current = d.f64("pivot", f64::from(CENTER)).clamp(0.0, 8.0) as usize; - if let Some(i) = widgets::reference_point(ui, current) { + if let Some(i) = widgets::reference_point_named(ui, current, tl!("Pivot:")) { d.fields.insert("pivot".into(), json!(i)); } }); diff --git a/crates/ui-egui/src/dialogs/placement_options.rs b/crates/ui-egui/src/dialogs/placement_options.rs index 55849558f..dbe8ff827 100644 --- a/crates/ui-egui/src/dialogs/placement_options.rs +++ b/crates/ui-egui/src/dialogs/placement_options.rs @@ -55,7 +55,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_enabled_ui(free, |ui| { widgets::label_row(ui, tl!("Alignment:"), LABEL, |ui| { let current = ALIGN.iter().position(|a| *a == d.str("align")).unwrap_or(4); - if let Some(i) = widgets::reference_point(ui, current) + if let Some(i) = widgets::reference_point_named(ui, current, tl!("Alignment:")) && let Some(a) = ALIGN.get(i) { d.fields.insert("align".into(), json!(a)); diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 76650401c..523c42d06 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -628,7 +628,7 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_enabled_ui(!tiles && !placed, |ui| { let origin = choice::(d, "placement.origin").unwrap_or_default(); let current = Origin::ALL.iter().position(|o| *o == origin).unwrap_or(4); - if let Some(o) = widgets::reference_point(ui, current).and_then(|i| Origin::ALL.get(i)) { + if let Some(o) = widgets::reference_point_named(ui, current, tl!("Placement:")).and_then(|i| Origin::ALL.get(i)) { set(d, "placement.origin", json!(o.id())); } ui.label("X:"); @@ -851,7 +851,7 @@ fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result, has_do Err(_) if !has_doc => {} Err(e) => { heading(ui, tl!("Error")); - ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text)); + ui.label(egui::RichText::new(super::save_pdf::warning_line(crate::i18n::current(), e)).color(t.text)); } } heading(ui, tl!("Options")); diff --git a/crates/ui-egui/src/dialogs/relink.rs b/crates/ui-egui/src/dialogs/relink.rs new file mode 100644 index 000000000..ceca12c04 --- /dev/null +++ b/crates/ui-egui/src/dialogs/relink.rs @@ -0,0 +1,316 @@ +//! Links panel → Relink…: the selected links, and the Replace choice of which links the new file +//! takes. Selected Instances relinks the selected images and placed documents, All Instances every +//! object linked to the same files, and Missing Links from This Folder the selected ones and then +//! every other missing link whose file name is in the new file's folder. Each Link Separately asks +//! for a file for each link in turn, one OK each, and relinks them all at the last one. Every +//! choice is one `links.relink` call: one undo step. +//! +//! Fields: `ids` (the images and placed documents), `links` (`[{name, ids}]`, one row per linked +//! file, as `links.check` lists them, then one per embedded image), `scope` (each|selected|all|missingInFolder; default +//! selected), `path` (the file; picked when empty) and `paths` (with each: the files chosen so +//! far, one per row of `links` in order; the next OK takes `path` or picks one for the next row). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use super::form; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; + +pub(super) const KIND: &str = "relink"; +const CMD: &str = "links.relink"; +/// (value, label) of each Replace choice. +const SCOPES: [(&str, &str); 4] = [ + ("each", "Each Link Separately"), + ("selected", "Selected Instances"), + ("all", "All Instances"), + ("missingInFolder", "Missing Links from This Folder"), +]; +const LABEL: f32 = 70.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Relink").into(), + body, + confirm, + ok: Some("Relink…"), + min_width: 400.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; + +/// Open the dialog on the images and placed documents `ids` (default: the selected ones). +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let ids = match p.get("ids").filter(|v| !v.is_null()) { + Some(v) => v.as_array().cloned().ok_or_else(|| format!("`ids` must be an array of object ids, not {v}"))?, + None => crate::panels::links::selected_ids(app).into_iter().map(Value::from).collect(), + }; + if ids.is_empty() { + return Err("select the images to relink, or pass ids".into()); + } + let check = app.session.execute("links.check", &json!({ "ids": ids })).map_err(|e| e.to_string())?; + // One row per file: objects showing different content of one file (pages of a PDF) share it. + let mut by_path: Vec<(Value, Value, Vec)> = vec![]; + for l in check["links"].as_array().into_iter().flatten() { + let ids = l["ids"].as_array().cloned().unwrap_or_default(); + match by_path.iter_mut().find(|(path, _, _)| *path == l["path"]) { + Some((_, _, all)) => all.extend(ids), + None => by_path.push((l["path"].clone(), l["name"].clone(), ids)), + } + } + let mut links: Vec = by_path.into_iter().map(|(_, name, ids)| json!({ "name": name, "ids": ids })).collect(); + // Embedded images have no link: each gets a row of its own, named as the Links panel lists it. + let linked: Vec<&Value> = links.iter().flat_map(|l| l["ids"].as_array().into_iter().flatten()).collect(); + let mut embedded: Vec = vec![]; + for id in ids.iter().filter(|id| !linked.contains(id)) { + if !embedded.contains(id) { + embedded.push(id.clone()); + } + } + if !embedded.is_empty() { + let list = app.session.execute("links.list", &json!({})).map_err(|e| e.to_string())?; + let rows = list["links"].as_array().cloned().unwrap_or_default(); + for id in embedded { + let name = rows.iter().find(|r| r["id"] == id).map_or(Value::Null, |r| r["name"].clone()); + links.push(json!({ "name": name, "ids": [id] })); + } + } + app.ui.dialog = Some(Dialog::new(KIND, json!({ "ids": ids, "links": links, "scope": "selected", "path": "", "paths": [] }))); + Ok(Value::Null) +} + +fn rows(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default() +} + +/// The file the next OK uses: `path`, else one picked (none: cancelled). +fn file(app: &mut VectorcraftApp, d: &Dialog) -> Result { + match d.str("path") { + p if !p.is_empty() => Ok(p), + _ => { + let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; + crate::picks::open(app, &pick).ok_or_else(|| "cancelled".to_string()) + } + } +} + +/// OK: relink with the chosen scope. With each, a file for the next link: the dialog stays open +/// until every link has one, then relinks them all. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let scope = d.str("scope"); + let r = if scope == "each" { + let links = rows(d, "links"); + let mut paths = rows(d, "paths"); + if paths.len() < links.len() { + paths.push(json!(file(app, d)?)); + } + if paths.len() < links.len() { + let mut next = d.clone(); + next.fields.insert("paths".into(), json!(paths)); + next.fields.insert("path".into(), json!("")); + app.ui.dialog = Some(next); + return Ok(json!({ "next": links.get(paths.len()).map_or(Value::Null, |l| l["name"].clone()) })); + } + let entries: Vec = links.iter().zip(&paths).map(|(l, p)| json!({ "ids": l["ids"], "path": p })).collect(); + app.run(CMD, json!({ "scope": "each", "paths": entries }))? + } else { + let path = file(app, d)?; + app.run(CMD, json!({ "ids": d.fields.get("ids").cloned().unwrap_or(Value::Null), "path": path, "scope": scope }))? + }; + app.ui.dialog = None; + let missed: Vec<&str> = r["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + app.status(crate::panels::links::relinked_status_missed(r["relinked"].as_array().map_or(0, Vec::len), &missed)); + Ok(r) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let links = rows(d, "links"); + let paths = rows(d, "paths"); + let each = d.str("scope") == "each"; + widgets::list_box(ui, |ui| { + ui.set_width(ui.available_width()); + for (i, l) in links.iter().enumerate() { + ui.horizontal(|ui| { + ui.add_space(6.0); + // With each: the link the next OK picks a file for is in bold. + let font = if each && i == paths.len() { theme::semibold(12.0) } else { egui::FontId::proportional(12.0) }; + ui.label(egui::RichText::new(l["name"].as_str().unwrap_or_default()).font(font).color(t.text_strong)); + if let Some(p) = paths.get(i).and_then(Value::as_str).filter(|_| each) { + let name = std::path::Path::new(p).file_name().map_or(p.into(), |n| n.to_string_lossy()); + ui.add(egui::Label::new(egui::RichText::new(format!("→ {name}")).size(11.5).color(t.text_dim)).truncate()).on_hover_text(p); + } + }); + } + }); + ui.add_space(8.0); + form::choice(ui, d, "scope", tl!("Replace:"), (LABEL, 260.0), &SCOPES); + false +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + fn png(rgb: [u8; 3]) -> Vec { + let mut out = vec![]; + let [r, g, b] = rgb; + image::RgbaImage::from_pixel(40, 30, image::Rgba([r, g, b, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + /// A fresh folder with the PNG files `names` → (app with a document, folder). + fn setup(name: &str, names: &[&str]) -> (VectorcraftApp, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("vectorcraft-relink-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("new")).unwrap(); + for n in names { + std::fs::write(dir.join(n), png([200, 0, 0])).unwrap(); + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + (app, dir) + } + + fn place(app: &mut VectorcraftApp, path: &std::path::Path) -> u64 { + app.run("file.place", json!({"path": path.to_string_lossy()})).unwrap()["ids"][0].as_u64().unwrap() + } + + fn link_path(app: &VectorcraftApp, id: u64) -> String { + let st = app.session.active().unwrap(); + match st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) { + Some(vectorcraft_doc::NodeKind::Image(im)) => im.link.as_ref().map(|l| l.path.clone()).unwrap_or_default(), + _ => String::new(), + } + } + + fn new_file(dir: &std::path::Path, name: &str) -> String { + let p = dir.join("new").join(name); + std::fs::write(&p, png([0, 0, 200])).unwrap(); + p.to_string_lossy().into_owned() + } + + #[test] + fn relink_opens_with_the_replace_choice_and_all_instances_relinks_every_copy() { + let (mut app, dir) = setup("all", &["photo.png", "other.png"]); + let (a, b, c) = (place(&mut app, &dir.join("photo.png")), place(&mut app, &dir.join("photo.png")), place(&mut app, &dir.join("other.png"))); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.services.pick_open = None; + assert!(!crate::menus::enabled(&app, "ui.relinkDialog"), "no file picker (the web)"); + let picked = new_file(&dir, "blue.png"); + let answer = picked.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(answer.clone()))); + assert!(crate::menus::enabled(&app, "ui.relinkDialog")); + app.run("ui.relinkDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the dialog"); + assert_eq!((d.kind.as_str(), d.str("scope"), d.fields["links"].as_array().unwrap().len()), (KIND, "selected".to_string(), 1)); + let text = frame(&mut app); + for label in ["Relink", "photo.png", "Replace:", "Selected Instances", "Relink…", "Cancel"] { + assert!(text.contains(label), "{label} in {text}"); + } + app.ui.dialog.as_mut().unwrap().fields.insert("scope".into(), json!("all")); + // OK picks the file. + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["relinked"], json!([a, b])); + assert!(app.ui.dialog.is_none()); + assert_eq!((link_path(&app, a), link_path(&app, b)), (picked.clone(), picked.clone())); + assert_eq!(link_path(&app, c), dir.join("other.png").to_string_lossy()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(link_path(&app, b), dir.join("photo.png").to_string_lossy(), "one undo step"); + // Nothing selected and no ids: nothing to relink. + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(!crate::menus::enabled(&app, "ui.relinkDialog")); + assert!(app.run("ui.relinkDialog", json!({})).is_err()); + assert!(app.run("ui.relinkDialog", json!({"ids": 3})).is_err(), "ids is a list"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn each_link_separately_asks_for_a_file_for_each_link_then_relinks_them_all() { + let (mut app, dir) = setup("each", &["one.png", "two.png"]); + let (a, b) = (place(&mut app, &dir.join("one.png")), place(&mut app, &dir.join("two.png"))); + let (x, y) = (new_file(&dir, "x.png"), new_file(&dir, "y.png")); + let mut answers = vec![y.clone(), x.clone()]; + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| answers.pop())); + app.run("ui.relinkDialog", json!({"ids": [a, b]})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("scope".into(), json!("each")); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r, json!({"next": "two.png"})); + let d = app.ui.dialog.as_ref().expect("asks for the next link's file"); + assert_eq!(d.fields["paths"], json!([x])); + assert_eq!(link_path(&app, a), dir.join("one.png").to_string_lossy(), "nothing relinked yet"); + assert!(frame(&mut app).contains("→ x.png")); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["relinked"], json!([a, b])); + assert!(app.ui.dialog.is_none()); + assert_eq!((link_path(&app, a), link_path(&app, b)), (x.clone(), y.clone())); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!( + (link_path(&app, a), link_path(&app, b)), + (dir.join("one.png").to_string_lossy().into_owned(), dir.join("two.png").to_string_lossy().into_owned()), + "one undo step" + ); + let _ = std::fs::remove_dir_all(dir); + } + + /// An embedded image in the selection gets a row of its own, so Each Link Separately asks for + /// its file too. + #[test] + fn embedded_images_get_their_own_rows() { + let (mut app, dir) = setup("embedded", &["one.png"]); + let (a, b) = (place(&mut app, &dir.join("one.png")), place(&mut app, &dir.join("one.png"))); + app.run("links.embed", json!({"ids": [b]})).unwrap(); + app.run("ui.relinkDialog", json!({"ids": [a, b, b]})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the dialog"); + let rows = d.fields["links"].as_array().unwrap(); + assert_eq!((rows.len(), &rows[0]["ids"], &rows[1]["ids"]), (2, &json!([a]), &json!([b]))); + let _ = std::fs::remove_dir_all(dir); + } + + /// Objects showing different content of one file share its row, so Each Link Separately asks + /// for that file once. + #[test] + fn one_file_with_two_contents_is_one_row() { + let (mut app, dir) = setup("one-file", &["photo.png"]); + let a = place(&mut app, &dir.join("photo.png")); + std::fs::write(dir.join("photo.png"), png([0, 200, 0])).unwrap(); + let b = place(&mut app, &dir.join("photo.png")); + app.run("ui.relinkDialog", json!({"ids": [a, b]})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the dialog"); + assert_eq!(d.fields["links"], json!([{"name": "photo.png", "ids": [a, b]}])); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_links_from_this_folder_relinks_the_others_found_there() { + let (mut app, dir) = setup("missing", &["a.png", "b.png"]); + let (a, b) = (place(&mut app, &dir.join("a.png")), place(&mut app, &dir.join("b.png"))); + let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); + app.run("document.save", json!({"path": doc})).unwrap(); + for n in ["a.png", "b.png"] { + std::fs::remove_file(dir.join(n)).unwrap(); + } + app.run("file.close", json!({})).unwrap(); + crate::io::open_path(&mut app, &doc).unwrap(); + // The Linked File Not Found question is left for the Links panel. + app.ui.dialog = None; + let chosen = new_file(&dir, "renamed.png"); + let found = new_file(&dir, "b.png"); + app.run("ui.relinkDialog", json!({"ids": [a]})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("scope".into(), json!("missingInFolder")); + d.fields.insert("path".into(), json!(chosen)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r, json!({"relinked": [a, b], "notFound": []})); + assert_eq!((link_path(&app, a), link_path(&app, b)), (chosen, found)); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 7146117b0..f3c7d7e35 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -60,7 +60,10 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } -fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { +/// The tabs and controls of the 3D effect in `d.fields["__effect"]`, editing `d.fields`; the +/// dialog's body and the 3D and Materials panel draw them. With `__panel` set (the panel), the +/// controls that only the dialog's artboard gizmos use are left out. +pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.spacing_mut().item_spacing.y = 4.0; let id = d.str("__effect"); if id != REVOLVE { @@ -95,14 +98,13 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit)); ui.horizontal(|ui| { - let mut cap = d.bool("cap"); + let cap = flag(d, "cap", true); let tip = tl!("Close the circular ends of open profiles. Partial sweeps keep their cut sides open."); - if widgets::check_tip(ui, c3("Cap"), cap, true, tip) { - cap = !cap; - d.fields.insert("cap".into(), json!(cap)); + if widgets::check3_tip(ui, c3("Cap"), cap, true, tip) { + d.fields.insert("cap".into(), json!(cap != Some(true))); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, "cap", !cap, "true") { + if reset_dot(ui, "cap", cap != Some(true), "true") { d.fields.insert("cap".into(), json!(true)); } }); @@ -115,20 +117,22 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(6.0); egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| { number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None); - super::form::check(ui, d, "__showAxis", tl!("Show axis")); + if !d.bool("__panel") { + super::form::check(ui, d, "__showAxis", tl!("Show axis")); + } ui.add_space(6.0); ui.horizontal(|ui| { - let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true); + let visible = flag(d, "expandVisibleOnly", true); let tip = tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces."); let toggled = ui.allocate_ui_with_layout( vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - widgets::check_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip) + widgets::check3_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip) }, ).inner; if toggled { - d.fields.insert("expandVisibleOnly".into(), json!(!visible)); + d.fields.insert("expandVisibleOnly".into(), json!(visible != Some(true))); } - if reset_dot(ui, "expandVisibleOnly", !visible, "true") { + if reset_dot(ui, "expandVisibleOnly", visible != Some(true), "true") { d.fields.insert("expandVisibleOnly".into(), json!(true)); } }); @@ -248,7 +252,9 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) { fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) { let id = d.str("__effect"); let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null); - let value = d.fields.get(key).cloned().unwrap_or_else(|| default.clone()); + // A mixed setting (null) shows its default, with its reset dot filled. + let mixed = d.fields.get(key).is_some_and(Value::is_null); + let value = d.fields.get(key).filter(|v| !v.is_null()).cloned().unwrap_or_else(|| default.clone()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; label(ui, name); @@ -261,38 +267,48 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[( d.fields.insert(key.into(), v.clone()); } let default_label = options.iter().find(|(v, _)| *v == default).map_or("", |(_, l)| *l); - if reset_dot(ui, key, value != default, default_label) { + if reset_dot(ui, key, mixed || value != default, default_label) { d.fields.insert(key.into(), default.clone()); } }); } -/// A checkbox for `key`, with its reset dot. +/// The checkbox setting `key`: `None` when it is null (the 3D and Materials panel's selected +/// effects hold it with different values), else its value, or `default` when it has none. +fn flag(d: &Dialog, key: &str, default: bool) -> Option { + match d.fields.get(key) { + Some(Value::Null) => None, + value => Some(value.and_then(Value::as_bool).unwrap_or(default)), + } +} + +/// A checkbox for `key`, with its reset dot. A mixed setting shows a dash, and a click turns it on. fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) { ui.horizontal(|ui| { - let on = d.bool(key); - if widgets::check(ui, name, on, ui.is_enabled()) { - d.fields.insert(key.into(), json!(!on)); + let on = flag(d, key, default); + if widgets::check3(ui, name, on, ui.is_enabled()) { + d.fields.insert(key.into(), json!(on != Some(true))); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, key, on != default, if default { "true" } else { "false" }) { + if reset_dot(ui, key, on != Some(default), if default { "true" } else { "false" }) { d.fields.insert(key.into(), json!(default)); } }); } fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { + let shade = flag(d, "shade", true); ui.horizontal(|ui| { - let shade = d.bool("shade"); - if widgets::check(ui, tl!("Shading"), shade, true) { - d.fields.insert("shade".into(), json!(!shade)); + if widgets::check3(ui, tl!("Shading"), shade, true) { + d.fields.insert("shade".into(), json!(shade != Some(true))); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, "shade", !shade, "true") { + if reset_dot(ui, "shade", shade != Some(true), "true") { d.fields.insert("shade".into(), json!(true)); } }); - ui.add_enabled_ui(d.bool("shade"), |ui| { + // A mixed Shading leaves the light settings on, for the effects that shade. + ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| { lighting_presets(ui, d); light_list(ui, d); let mut selected = selected_light(d); @@ -413,8 +429,10 @@ fn light_list(ui: &mut egui::Ui, d: &mut Dialog) { super::revolve_gizmo::clear(ui.ctx()); } }); + // Add Light is off while a setting of the single light is mixed (null). + let mixed = explicit_lights(d).is_none() && LIGHT_KEYS.iter().any(|(_, key)| d.fields.get(*key).is_some_and(Value::is_null)); ui.horizontal(|ui| { - if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS, egui::Button::new(tl!("Add Light"))).clicked() { + if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS && !mixed, egui::Button::new(tl!("Add Light"))).clicked() { let mut added = selected_light(d); let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({})); for (_, key) in LIGHT_KEYS { @@ -458,7 +476,8 @@ fn light_color(ui: &mut egui::Ui, d: &mut Dialog) { } ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])); ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, "lightColor", rgb != [255; 3], "#FFFFFF") { + let mixed = d.fields.get("lightColor").is_some_and(Value::is_null); + if reset_dot(ui, "lightColor", mixed || rgb != [255; 3], "#FFFFFF") { d.fields.insert("lightColor".into(), json!("#ffffff")); } }); @@ -550,7 +569,9 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, if let Some(n) = slider.0.map(|n| rail_min + f64::from(n) * (rail_max - rail_min)).or(slider.2) { next = Some(n); } - if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { + // A setting the 3D and Materials panel's selected effects hold with different values is null. + let mixed = d.fields.get(key).is_some_and(Value::is_null); + if reset_dot(ui, key, mixed || (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { next = Some(from(default)); } }); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 72a8d64ac..ee68105a6 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -792,7 +792,7 @@ fn summary(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { Ok(v) => v, Err(e) => { heading(ui, tl!("Error")); - ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text)); + ui.label(egui::RichText::new(warning_line(crate::i18n::current(), &e)).color(t.text)); return; } }; @@ -829,9 +829,14 @@ pub(super) fn warning_rows(ui: &mut egui::Ui, warnings: &[Value]) { note(ui, tl!("None.")); } for w in warnings { - ui.label(egui::RichText::new(format!("⚠ {}", w.as_str().unwrap_or_default())).color(t.text)); + ui.label(egui::RichText::new(warning_line(crate::i18n::current(), w.as_str().unwrap_or_default())).color(t.text)); } } +/// One of the Summary's warnings or errors, in `lang` as far as its catalog has it. +pub(super) fn warning_line(lang: crate::i18n::Lang, w: &str) -> String { + format!("⚠ {}", crate::i18n::message(lang, w)) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index 9f20acd62..8c13a9b61 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -126,6 +126,17 @@ fn warnings_reach_the_status_and_errors_keep_the_dialog_open() { assert!(r["warnings"].is_array()); } +/// The Summary's warnings are translated as far as the catalog has them: a warning without a row +/// keeps its words, and a colon in it can still take the catalog's punctuation. +#[test] +fn summary_warnings_are_translated() { + let fr = crate::i18n::Lang::from_code("fr").unwrap(); + let shown = warning_line(fr, vectorcraft_engine::cmd::fileio::pdf::BLEED_LEFT_OUT); + assert!(shown.starts_with("⚠ ") && !shown.contains("leaves out"), "{shown}"); + assert_eq!(warning_line(fr, "no row for this note"), "⚠ no row for this note"); + assert_eq!(warning_line(fr, "no row: for this note"), "⚠ no row\u{a0}: for this note"); +} + /// A placed image whose pixel data can't be decoded is left out of the PDF, and the status bar /// shows the note raster exports give. #[test] diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index cdeca225b..9583b893e 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -253,35 +253,27 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn hex_field(ui: &mut egui::Ui, hex: &str) -> Option { let t = Tokens::get(ui.ctx()); let id = ui.scope_id().with("swatch-hex"); - let editing = ui.memory(|m| m.has_focus(id)); - // As in `widgets::hex_field`: until the user types into it, the field shows `hex` as it is now - // (#1692). - let edited_id = id.with("edited"); - let edited = editing && ui.data_mut(|m| m.get_temp::(edited_id)).unwrap_or(false); - let mut buf: String = if edited { ui.data_mut(|m| m.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(egui::Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(2) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - crate::theme::strong_selected_text(ui); - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .align(egui::Align2::LEFT_CENTER) - .desired_width(70.0) - .char_limit(7), - ) - }) - .inner; - let focused = ui.memory(|m| m.has_focus(id)); - ui.data_mut(|m| { - m.insert_temp(id, buf.clone()); - m.insert_temp(edited_id, (edited || resp.changed()) && focused); - }); - (resp.lost_focus() && buf != hex).then(|| parse_hex(&buf)).flatten() + widgets::hex_text(ui, id, hex, |ui, buf, editing| { + egui::Frame::NONE + .fill(t.input) + .stroke(egui::Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(2) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + crate::theme::strong_selected_text(ui); + ui.add( + egui::TextEdit::singleline(buf) + .id(id) + .frame(egui::Frame::NONE) + .align(egui::Align2::LEFT_CENTER) + .desired_width(70.0) + .char_limit(widgets::HEX_FIELD_CHARS), + ) + }) + .inner + }) + .as_deref() + .and_then(parse_hex) } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index f388cb4ec..79e26764d 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -123,7 +123,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.end_row(); label(ui, tl!("Registration:")); let cur = d.fields.get("registration").and_then(Value::as_u64).unwrap_or(4) as usize; - if let Some(i) = widgets::reference_point(ui, cur) { + if let Some(i) = widgets::reference_point_named(ui, cur, tl!("Registration:")) { d.fields.insert("registration".into(), json!(i)); } ui.end_row(); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8ab9cb9b8..51959c320 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -463,6 +463,39 @@ fn dialogs_do_not_stretch_to_the_screen() { assert!(checked >= 10, "only {checked} dialogs drew in the shared frame"); } +#[test] +fn workspace_and_toolbar_dialogs_end_at_their_buttons() { + // New Workspace, Manage Workspaces, New Toolbar and Manage Toolbars draw their own windows: each + // ends just below its OK button, with no empty band above or below the buttons. + for (kind, fields, window) in [ + ("newWorkspace", json!({"name": "Mine"}), "dialog-workspace"), + ("manageWorkspaces", json!({"selected": "", "name": ""}), "dialog-workspace"), + (crate::custom_toolbars::KIND, json!({"name": "Toolbar 1"}), "dialog-new-toolbar"), + (crate::custom_toolbars::MANAGE, json!({"selected": "", "name": ""}), "dialog-manage-toolbars"), + ] { + let mut app = app(); + open(&mut app, kind, fields); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let mut ok = None; + for _ in 0..4 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show(&mut app, ui.ctx())); + out.textures_delta.clear(); + ok = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "OK" => Some(t.visual_bounding_rect()), + _ => None, + }); + } + let ok = ok.unwrap_or_else(|| panic!("{kind} draws no OK button")); + let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(window))).unwrap(); + let below = outer.bottom() - ok.bottom(); + assert!(below < 40.0, "{kind}: {below} pt between OK and the bottom of its {} pt window", outer.height()); + } +} + /// The dialog window of `app`'s open dialog after a few frames in a `w` × 900 window. fn dialog_rect(app: &mut VectorcraftApp, w: f32) -> egui::Rect { let ctx = egui::Context::default(); @@ -592,6 +625,8 @@ impl PluginIntField { kind: vectorcraft_plugins::Kind::Filter, description: String::new(), author: String::new(), + api: 1, + permissions: None, params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), @@ -952,6 +987,115 @@ fn tab_through_the_hex_field_keeps_the_value_typed_before_it() { } } +/// A hex code pasted into the # field of the Color Picker or Swatch Options after a click replaces +/// its digits, with or without `#`. Enter, Tab and a press on another field each take it. A paste +/// that is not a color changes nothing, and Enter then closes on the color from before it. +#[test] +fn a_hex_code_pasted_into_the_hex_field_replaces_its_digits() { + let mut app = app(); + app.run("swatch.new", json!({"name": "Sky", "color": "#3366cc"})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + // One frame of the dialog layer: every text drawn, with its center. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut texts = vec![]; + out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); + texts + }; + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + // The context keeps a key down until it is let go, and a dialog ignores a repeat of Enter. + let release = |key| egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + type Open = fn(&mut VectorcraftApp); + type Applied = fn(&VectorcraftApp) -> String; + let dialogs: [(&str, Open, Applied, &str, &str); 2] = [ + ( + "Color Picker", + |app| drop(app.run("ui.colorPicker", json!({"stroke": false, "color": "#ffffff"}))), + |app| crate::panels::current_paints(app).0.color().unwrap().to_hex(), + "FFFFFF", + "255", + ), + ( + "Swatch Options", + |app| drop(app.run("ui.swatchOptions", json!({"name": "Sky"}))), + |app| app.session.active().unwrap().doc.swatch("Sky").unwrap().paint.color().unwrap().to_hex(), + "3366CC", + "204", + ), + ]; + for (name, open, applied, hex, other) in dialogs { + // Opens the dialog and clicks into its # field. → The texts it shows. + let click_in = |app: &mut VectorcraftApp| { + open(app); + for _ in 0..3 { + frame(app, vec![]); + } + let texts = frame(app, vec![]); + let at = texts.iter().find(|(t, _)| t == hex).unwrap_or_else(|| panic!("{name}: no # field shows {hex}")).1; + frame(app, vec![egui::Event::PointerMoved(at), button(at, true)]); + frame(app, vec![button(at, false)]); + frame(app, vec![]); + texts + }; + let before = applied(&app); + // The click selects the digits, so typing replaces them. + click_in(&mut app); + frame(&mut app, vec![egui::Event::Text("00FF00".into())]); + assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: typing after a click replaces the digits"); + cancel(&mut app); + frame(&mut app, vec![]); + // A paste that is not a color leaves the field as it was. + for paste in ["hello", "12345", "#12345", "12345678", "#00FF00 and more", ""] { + click_in(&mut app); + let shows = frame(&mut app, vec![egui::Event::Paste(paste.into())]); + assert!(shows.iter().any(|(t, _)| t == hex), "{name}: the # field keeps {hex} after pasting {paste:?}"); + frame(&mut app, vec![key(egui::Key::Enter)]); + frame(&mut app, vec![release(egui::Key::Enter)]); + assert!(app.ui.dialog.is_none(), "{name}: Enter closes the dialog after pasting {paste:?}"); + assert_eq!(applied(&app), before, "{name}: pasting {paste:?} changes no color"); + } + // Tab takes the pasted code. + click_in(&mut app); + frame(&mut app, vec![egui::Event::Paste("#00FF00".into())]); + frame(&mut app, vec![key(egui::Key::Tab)]); + frame(&mut app, vec![]); + assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: Tab takes the pasted code"); + cancel(&mut app); + frame(&mut app, vec![]); + // So does a press on another field, which is drawn before the # field. + let texts = click_in(&mut app); + let away = texts.iter().find(|(t, _)| t == other).unwrap_or_else(|| panic!("{name}: no field shows {other}: {texts:?}")).1; + frame(&mut app, vec![egui::Event::Paste("00FF00".into())]); + frame(&mut app, vec![egui::Event::PointerMoved(away), button(away, true)]); + frame(&mut app, vec![button(away, false)]); + frame(&mut app, vec![]); + assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: a press elsewhere takes the pasted code"); + cancel(&mut app); + frame(&mut app, vec![]); + // Enter takes it and closes the dialog with the color. + for paste in ["00FF00", "#00ff00", " #00FF00\n", "0f0"] { + click_in(&mut app); + frame(&mut app, vec![egui::Event::Paste(paste.into())]); + frame(&mut app, vec![key(egui::Key::Enter)]); + frame(&mut app, vec![release(egui::Key::Enter)]); + assert!(app.ui.dialog.is_none(), "{name}: Enter closes the dialog after pasting {paste:?}"); + assert_eq!(applied(&app), "#00ff00", "{name}: Enter applies {paste:?}"); + app.run("swatch.edit", json!({"name": "Sky", "color": "#3366cc"})).unwrap(); + } + } +} + /// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed /// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid. #[test] @@ -1630,6 +1774,66 @@ fn artboard_options_show_center_mark_and_cross_hairs() { } } +/// Artboard Options shows Fade Region Outside Artboard and Update While Dragging under Global, +/// ticked as the preferences have them (on by default). OK sets the preferences, for the dialog of +/// an existing artboard and of a new one alike, and the artboard keeps its other settings. +#[test] +fn artboard_options_global_checkboxes_set_the_preferences() { + let mut app = app(); + let prefs = |app: &VectorcraftApp| (app.session.prefs.fade_region_outside_artboard, app.session.prefs.artboard_update_while_dragging); + assert_eq!(prefs(&app), (true, true)); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(text.contains("Global") && text.contains("Fade Region Outside Artboard") && text.contains("Update While Dragging"), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.bool("fadeRegionOutsideArtboard") && d.bool("artboardUpdateWhileDragging")); + d.fields.insert("fadeRegionOutsideArtboard".into(), json!(false)); + let rect = app.session.doc().unwrap().doc.artboards[0].rect; + super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(prefs(&app), (false, true)); + assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, rect); + // Opened again, it shows them as set; a value that is not true or false is refused and changes + // nothing. A new artboard's dialog sets them too. + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("fadeRegionOutsideArtboard")); + d.fields.insert("artboardUpdateWhileDragging".into(), json!("sometimes")); + assert!(super::confirm(&mut app).is_err()); + assert_eq!(prefs(&app), (false, true), "nothing changed"); + cancel(&mut app); + app.run("ui.newArtboard", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("fadeRegionOutsideArtboard") && d.bool("artboardUpdateWhileDragging")); + d.fields.insert("fadeRegionOutsideArtboard".into(), json!(true)); + d.fields.insert("artboardUpdateWhileDragging".into(), json!(false)); + super::confirm(&mut app).unwrap(); + assert_eq!(prefs(&app), (true, false)); + assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 2); +} + +/// When the artboard command refuses a value, OK leaves the Global checkboxes' preferences as they +/// were, for the dialog of an existing artboard and of a new one alike. +#[test] +fn artboard_options_error_leaves_the_global_preferences_unchanged() { + let mut app = app(); + let prefs = |app: &VectorcraftApp| (app.session.prefs.fade_region_outside_artboard, app.session.prefs.artboard_update_while_dragging); + for new in [false, true] { + if new { + app.run("ui.newArtboard", json!({})).unwrap(); + } else { + crate::dialogs::artboard_options::open(&mut app).unwrap(); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("fadeRegionOutsideArtboard".into(), json!(false)); + d.fields.insert("videoRulerPixelAspectRatio".into(), json!(20)); + assert!(super::confirm(&mut app).is_err(), "new: {new}"); + assert_eq!(prefs(&app), (true, true), "new: {new}"); + assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 1, "new: {new}"); + cancel(&mut app); + } +} + /// Artboard Options shows Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (one row of /// it), set as the artboard has them whether it opens from the Artboards panel or from a /// double-click with the Artboard tool. OK sets them in one undo step (a typed ratio is read as a diff --git a/crates/ui-egui/src/dialogs/tests_modal.rs b/crates/ui-egui/src/dialogs/tests_modal.rs index 95ff410ca..c20209410 100644 --- a/crates/ui-egui/src/dialogs/tests_modal.rs +++ b/crates/ui-egui/src/dialogs/tests_modal.rs @@ -453,9 +453,10 @@ fn simplify_slider_escape_puts_the_paths_back() { let undo_len = app.session.doc().unwrap().history.undo.len(); crate::menus::invoke(&mut app, "object.path.simplify", json!({})); settle(&ctx, &mut app); + // Level 0's tolerance of 32 pt takes out the path's 0.1 pt zigzag; level 100's 1/32 pt keeps it. let (_, _, rail, _) = slider_strip(&ctx); - drag(&ctx, &mut app, rail, vec2(100.0, 0.0)); - assert_eq!(simplify_preview(&app), json!({"level": 100.0})); + drag(&ctx, &mut app, rail + vec2(90.0, 0.0), vec2(-190.0, 0.0)); + assert_eq!(simplify_preview(&app), json!({"level": 0.0})); assert_ne!(app.session.doc().unwrap().doc, before, "the preview changes the path"); let escape = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), events: vec![escape], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs index c88f79613..97255bd22 100644 --- a/crates/ui-egui/src/dialogs/tests_perspective.rs +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -71,6 +71,50 @@ fn define_grid_opens_on_the_grid_and_applies_its_fields() { assert_eq!(kind(&app), Some(super::perspective_grid::KIND)); } +/// A three-digit code pasted or typed into a Define Grid color field sets that grid color. +#[test] +fn define_grid_takes_a_three_digit_color() { + let mut app = app(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + // One frame of the dialog layer: every text drawn, with its center. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::show(app, ui.ctx())); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut texts = vec![]; + out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); + texts + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // The context keeps a key down until it is let go, and a dialog ignores a repeat of Enter. + let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + for (how, input) in [("pasted", egui::Event::Paste("0f0".into())), ("typed", egui::Event::Text("0f0".into()))] { + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + set(&mut app, "leftColor", json!("#123456")); + for _ in 0..3 { + frame(&mut app, vec![]); + } + let texts = frame(&mut app, vec![]); + let at = texts.iter().find(|(t, _)| t == "123456").unwrap_or_else(|| panic!("no Left Grid field in {texts:?}")).1; + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true)]); + frame(&mut app, vec![button(at, false)]); + frame(&mut app, vec![]); + frame(&mut app, vec![input]); + frame(&mut app, vec![enter(true)]); + frame(&mut app, vec![enter(false)]); + assert_eq!(kind(&app), None, "{how}: Enter closes the dialog"); + assert_eq!(grid(&app).left_color.hex(), "#00ff00", "{how}: the grid takes the color"); + } +} + #[test] fn define_grid_is_a_view_menu_item() { let app = app(); diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index eb79cf357..1a4c54989 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -598,6 +598,9 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { // it open. app.ui.tools_drawer = false; } + if app.ui.tools_drawer { + crate::toolbar::mark_flyout_open(ctx); + } ghost(ctx, &t); } diff --git a/crates/ui-egui/src/export_progress.rs b/crates/ui-egui/src/export_progress.rs index 02d1ab7ff..3d392c3a3 100644 --- a/crates/ui-egui/src/export_progress.rs +++ b/crates/ui-egui/src/export_progress.rs @@ -1,7 +1,8 @@ //! The export progress dialog: while an export runs in the background (Background Export) a modal //! window shows what is being exported, how far it has got (artboard by artboard for raster -//! exports of several artboards, file by file for Export for Screens; busy for one artboard and for -//! formats made in one go, which report no steps) and two buttons: Cancel (`file.cancelExport`, also +//! exports of several artboards, file by file for Export for Screens, as a share for an exporter +//! plug-in that reports its progress; busy for one artboard and for formats made in one go, which +//! report no steps) and two buttons: Cancel (`file.cancelExport`, also //! Esc) stops it and writes nothing (Export for Screens keeps the files it wrote before the //! Cancel), Continue in Background (`file.exportInBackground`) closes the //! dialog and leaves the export to the status bar. @@ -24,8 +25,8 @@ fn seen_id() -> egui::Id { } /// What the dialog shows: (label, steps done, steps in all, share done, exports after this one, -/// whether the steps are files). -fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, bool)> { +/// what the steps are). +fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, Steps)> { let jobs: Vec<_> = app.background.jobs.iter().filter(|j| !j.hidden && !j.cancelled()).filter(|j| j.progress.is_some()).collect(); let first = jobs.first()?; let since = ctx.data_mut(|m| { @@ -40,8 +41,22 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, } let (done, total) = first.progress.as_ref().map(|p| p.counts()).unwrap_or_default(); let frac = first.progress.as_ref().and_then(|p| p.fraction()).unwrap_or(0.0) as f32; - let files = first.progress.as_ref().is_some_and(|p| p.counts_files()); - Some((first.label.clone(), done, total, frac, jobs.len() - 1, files)) + let steps = match &first.progress { + Some(p) if p.counts_share() => Steps::Share, + Some(p) if p.counts_files() => Steps::Files, + _ => Steps::Artboards, + }; + Some((first.label.clone(), done, total, frac, jobs.len() - 1, steps)) +} + +/// What an export's steps are. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Steps { + Artboards, + /// Files (Export for Screens). + Files, + /// An exporter plug-in's own steps, shown as a share of the work. + Share, } /// Draws the window for the first export running in the background that wasn't hidden or @@ -49,7 +64,7 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, /// of the button pressed. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); - let Some((label, done, total, frac, more, files)) = shown(app, now, ctx) else { return }; + let Some((label, done, total, frac, more, steps)) = shown(app, now, ctx) else { return }; if app.ui.dialog.is_some() { return; } @@ -57,8 +72,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let heading = format!("{}…", crate::i18n::msg(&label)); let pressed = window(ctx, "export-progress", &heading, |ui| { // One step says nothing a spinner doesn't: the bar would sit at 0 % until the file is written. - if total > 1 { - let step = if files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") }; + if steps == Steps::Share && total > 0 { + share(ui, tl!("Encoding and writing the file…"), frac); + } else if total > 1 { + let step = if steps == Steps::Files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") }; let step = crate::i18n::fmt(step, &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]); // The count on its own line: drawn over the bar it would straddle the fill's edge. ui.horizontal(|ui| { @@ -126,6 +143,24 @@ pub fn window(ctx: &egui::Context, id: &str, heading: &str, body: impl FnOnce(&m pressed } +/// Work that reports the share it has done (0 to 1), in a [`window`]: `what` it does, in the UI +/// language, and the share as a percentage, over a bar filled that far. A plug-in's own steps are +/// shown this way. +pub fn share(ui: &mut egui::Ui, what: &str, done: f32) { + let t = Tokens::get(ui.ctx()); + let done = if done.is_finite() { done.clamp(0.0, 1.0) } else { 0.0 }; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(what).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(format!("{:.0} %", done * 100.0)).color(t.text_dim)); + }); + }); + ui.add_space(4.0); + ui.add(egui::ProgressBar::new(done).desired_width(360.0).desired_height(6.0).fill(t.accent)); + // The work reports from another thread: draw what it reports next. + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); +} + /// Work that counts no steps, in a [`window`]: a spinner and `what` it does, in the UI language. pub fn busy(ui: &mut egui::Ui, what: &str) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 39dd49bfc..1896b8ed9 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -443,12 +443,17 @@ struct WheelField { #[derive(Clone)] struct QueuedWheel { + #[cfg(not(target_arch = "wasm32"))] frame: u64, field: egui::Id, + #[cfg(not(target_arch = "wasm32"))] menu: egui::Id, + #[cfg(not(target_arch = "wasm32"))] position: egui::Pos2, rect: egui::Rect, + #[cfg(not(target_arch = "wasm32"))] event: egui::Event, + #[cfg(not(target_arch = "wasm32"))] amount: f32, reset_after: bool, positions_after: Vec, @@ -456,6 +461,7 @@ struct QueuedWheel { #[derive(Clone)] struct ReplayWheel { + #[cfg(not(target_arch = "wasm32"))] target: egui::LayerId, position: egui::Pos2, event: egui::Event, @@ -791,6 +797,7 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y, }; if cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() != raw.viewport_id { + #[cfg(not(target_arch = "wasm32"))] if let Some(position) = pointer { fields.queued.push(QueuedWheel { frame, diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f6a8debdc..e9a219bd5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -70,6 +70,11 @@ 3 Color Job… Задача с 3 цвята… 30° Angles Ъгли от 30° 3D and Materials 3D и материали + Plane Равнина + Extrude Избутване + Revolve Завъртане около ос + Inflate Издуване + Select objects, then pick an object type to give them a 3D effect. Изберете обекти, след това изберете тип обект, за да им придадете 3D ефект. 3D Ribbon 3D лента 45° Angles Ъгли от 45° 60° Angles Ъгли от 60° @@ -271,6 +276,8 @@ font-technology Variable Променливи Show Cross Hairs Показване на кръстосани линии Show Video Safe Areas Показване на безопасните видеозони Video Ruler Pixel Aspect Ratio: Пропорция на пиксела на видеолинийките: + Fade Region Outside Artboard Затъмняване на областта извън работната област + Update While Dragging Обновяване при плъзгане Hide Video Rulers Скриване на видеолинийките Background Contents Съдържание на фона Background Contents: Съдържание на фона: @@ -1060,6 +1067,8 @@ font-technology Variable Променливи Export for Screens Settings Настройки за експортиране за екрани Export for Screens… Експортиране за екрани… Export Keyboard Shortcuts… Експортиране на клавишни комбинации… + Export Keyboard Shortcuts as Text… Експортиране на клавишни комбинации като текст… + Export Text… Експортиране на текст… Export Option Опция за експортиране Export PDF Експортиране в PDF Export PDF Presets Предварителни настройки за PDF @@ -1327,6 +1336,8 @@ font-technology Variable Променливи Grays Сиви Grays and Neutrals Сиви и неутрални Grayscale Черно-бяло + High Color Много цветове + Low Color Малко цветове Grayscale images Черно-бели изображения Greek & Cyrillic Гръцки и кирилица Green Зелено @@ -1399,6 +1410,7 @@ font-technology Variable Променливи Step Backward Стъпка назад Step Forward Стъпка напред Clear History Изчистване на историята + New Document from State Нов документ от състоянието Set History Limit… Задаване на ограничение на историята… History States Състояния на историята # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1761,6 +1773,9 @@ font-technology Variable Променливи Live Paint Selection Tool Инструмент за селекция на динамично оцветяване Live Shape Properties Свойства на динамична фигура Load Color Profile… Зареждане на файлов цвят… + Load Character Styles… Зареждане на стилове на знаци… + Load Paragraph Styles… Зареждане на стилове на абзац… + Load All Styles… Зареждане на всички стилове… Remove Color Profile… Премахване на цветови профил… Load Image into Clipboard Зареждане на изображение в клипборда Load Metafile into Clipboard Зареждане на метафайл в клипборда @@ -2623,6 +2638,14 @@ digits Malayalam Малаялам Relink Свързване наново Relink Linked Files to the Document Свързване наново на свързаните файлове към документа Relink to Folder… Свързване наново към папка… + Each Link Separately Всяка връзка поотделно + Selected Instances Избраните екземпляри + All Instances Всички екземпляри + Missing Links from This Folder Липсващите връзки от тази папка +@msg scope {v} is not each|selected|all|missingInFolder scope {v} не е each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each и paths вървят заедно: paths изброява записи с ids и path вместо ids, path и folder +@msg paths entry {i} needs path and ids (object ids no other entry has) записът {i} в paths изисква path и ids (идентификатори на обекти, които няма друг запис) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder приема path: в папката на този файл се търсят другите липсващи връзки Relink… Свързване наново… Find Linked Files Търсене на свързани файлове Find Linked Files in Folder… Търсене на свързани файлове в папка… @@ -2667,6 +2690,7 @@ digits Malayalam Малаялам Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Заменете го с друг файл или го игнорирайте: неговите изображения ще продължат да показват записания с документа преглед. Replace Spine Замяна на оста Replace Symbol Замяна на символ + Reset Transformation Нулиране на трансформацията Replace With Font Замяна с шрифт Replace With Font From Замяна с шрифт от System Система @@ -2683,6 +2707,8 @@ digits Malayalam Малаялам Reset OpenType Features Нулиране на OpenType характеристиките Reset Panel Нулиране на панела Reset Preferences Нулиране на настройките + Search Preferences Търсене в настройките + No settings found Няма намерени настройки Reset Profiles Нулиране на профилите Reset Rotate View Нулиране на изгледа на завъртане Rotate View to Selection Завъртане на изгледа спрямо селекцията @@ -2737,6 +2763,7 @@ digits Malayalam Малаялам Rotate Tool Инструмент „Завъртане“ Rotate View Tool Инструмент „Завъртане на изглед“ Trackpad Gesture to Rotate View Завъртане на изгледа с жест на тракпада + Hold a Tool Shortcut to Use the Tool Temporarily Задържане на клавиша на инструмент за временното му използване Rotate… Завъртане… Rotation Завъртане Roughen… Грапавост… @@ -3162,6 +3189,7 @@ digits Malayalam Малаялам Spaces Интервали Spacing Guides Водачи за разстояние Invoke Distance Guides Извикване на водачи за отстояния + Snap to Last Location Прилепване към последното местоположение Spacing: Разстояние: Spanish Испански Specified Distance Упоменато разстояние @@ -3841,6 +3869,9 @@ axis Both И двете Put back in the toolbar Връщане в лентата с инструменти Float Panel Плаващ панел Dock Panel Закотвен панел + Dock to Top Закотвяне горе + Dock to Bottom Закотвяне долу + Dock Control Bar Закотвяне на контролната лента Drag to float the toolbar Плъзнете, за да направите лентата с инструменти плаваща Drag to the window's left edge to dock Плъзнете до левия ръб на прозореца за закотвяне Float Tool Group Плаваща група инструменти @@ -4163,13 +4194,13 @@ axis Both И двете @msg no point {i} (the gradient has {_1}) няма точка {i} (градиентът има {_1}) @msg no preset editor няма редактор на предварителни настройки @msg no previous transform няма предишно трансформиране -@msg no print preset named `{base}` няма предварителна настройка за печат с име „{base}“ -@msg no print preset named `{name}` няма предварителна настройка за печат с име „{name}“ +@msg no print preset named `{base}` няма предварителна настройка за печат с име `{base}` +@msg no print preset named `{name}` няма предварителна настройка за печат с име `{name}` @msg no print service няма услуга за печат @msg no revert command няма команда за връщане -@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) няма запазен PDF просет с име „{name}“ (вградените настройки са само за четене: създайте нова от тях) +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) няма запазен PDF просет с име `{name}` (вградените настройки са само за четене: създайте нова от тях) @msg no saved presets няма запазени предварителни настройки -@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) няма запазена предварителна настройка за печат с име „{name}“ ({DEFAULT_PRESET} е защитена: създайте нова от нея) +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) няма запазена предварителна настройка за печат с име `{name}` ({DEFAULT_PRESET} е защитена: създайте нова от нея) @msg no saved profiles няма запазени профили @msg no saved selection recalled няма възстановена запазена селекция @msg no saved selections няма запазени селекции @@ -4207,13 +4238,13 @@ axis Both И двете @msg no such user library няма такава потребителска библиотека @msg no such view няма такъв изглед @msg no such width point няма такава точка за ширина -@msg no swatch `{name}` няма образец „{name}“ -@msg no swatch library `{l}` (see swatch.library.list) няма библиотека с образци „{l}“ (вижте swatch.library.list) +@msg no swatch `{name}` няма образец `{name}` +@msg no swatch library `{l}` (see swatch.library.list) няма библиотека с образци `{l}` (вижте swatch.library.list) @msg no symbol instances selected няма избрани екземпляри на символ @msg no target object has an opacity mask: select one or give `id` никой целеви обект няма маска за непрозрачност: изберете един или задайте `id` @msg no threaded text selected няма избран свързан текст @msg no user library folder here тук няма папка с потребителска библиотека -@msg no user workspace `{from}` няма потребителско работно пространство „{from}“ +@msg no user workspace `{from}` няма потребителско работно пространство `{from}` @msg node {_1} is not text възелът {_1} не е текст @msg not a DXF drawing не е DXF чертеж @msg not a VectorCraft file: {0} не е файл на VectorCraft: {0} @@ -4239,6 +4270,7 @@ axis Both И двете @msg nothing to save: the document has no visible art няма нищо за запазване: документът няма видима графика @msg nothing to undo няма какво да се отмени @msg no history to clear няма история за изчистване +@msg no history state {v} няма състояние в историята {v} @msg nothing to warp няма нищо за извиване @msg nothing to wrap няма нищо за обвиване @msg object {0} cannot have children обектът {0} не може да има дъщерни елементи @@ -4475,6 +4507,7 @@ axis Both И двете @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} изображение не може да бъде декодирано: показва се като зачеркната рамка|{n} изображения не могат да бъдат декодирани: показват се като зачеркнати рамки @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} свързано изображение е променено на диска: „Обновяване на връзките“ показва новата версия|{n} свързани изображения са променени на диска: „Обновяване на връзките“ показва новите версии @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Обновен е {n} свързан файл, променен на диска|Обновени са {n} свързани файла, променени на диска +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings документът задава изтичане, което този PDF/X файл пропуска: включете „Използване на настройките за изтичане на документа“ @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} е експортиран с {n} бележка: {first}|{what} е експортиран с {n} бележки: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} шрифтов файл вече е в {folder}|{n} шрифтови файла вече са в {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} шрифтов файл вече е в {folder}; {failed} не можаха да бъдат добавени: {reason}|{n} шрифтови файла вече са в {folder}; {failed} не можаха да бъдат добавени: {reason} @@ -4600,6 +4633,7 @@ axis Both И двете ICF Box Касетка ICF @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` е референцията за стъпка {r}, която се разрешава само от `run` и `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` трябва да бъде масив от идентификатори на обекти, а не {v} +@msg `colors` must be an array of colors, not {v} `colors` трябва да бъде масив от цветове, а не {v} @msg `corners` must be an array of anchor indices, not {v} `corners` трябва да бъде масив от индекси на опорни точки, а не {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: резултатът от стъпка {i} няма такава стойност (напишете $$ за литерален знак $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: няма стъпка {i} преди тази (стъпките се броят от 0; напишете $$ за литерален знак $) @@ -4728,10 +4762,10 @@ effect Soft Меко @msg anchor must name a registered panel опорната точка трябва да указва регистриран панел @msg before must name a panel параметърът „преди“ трябва да указва панел @msg docking action contains an unknown panel or an excessive split path действието по закачане съдържа непознат панел или прекомерен път на разделяне -@msg group must be true or false групата трябва да е истина или лъжа +@msg group must be true or false group трябва да е true или false @msg panel is not in a dock group панелът не е в група на дока @msg only a dock group above or below others collapses, not one beside another свива се само група на дока над или под други, не и такава до друга -@msg icon must be true or false icon трябва да е истина или лъжа +@msg icon must be true or false icon трябва да е true или false @msg icon only docks a panel in the icon column, with no anchor icon закачва панел само в колоната с икони, без anchor @msg column docks a set as a dock column, with no anchor or icon column закачва набор като колона на дока, без anchor или icon @msg move requires a destination panel преместването изисква целеви панел @@ -5672,6 +5706,17 @@ axis Y Axis Y ос The plug-in is computing… Приставката изчислява… Inspect Plug-in Проверка на приставка Cancel Plug-in Run Отказ от изпълнението на приставката +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Да се инсталира ли „{name}“, който иска тези права? + ID: {id} ID: {id} + Version: {version} Версия: {version} + File SHA-256: {hash} SHA-256 на файла: {hash} + Read the objects it is given Четене на обектите, които получава + Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа + Keep data of its own with the document Съхраняване на собствени данни в документа + Change how the objects its effect is applied to are drawn Промяна на начина, по който се рисуват обектите, към които е приложен ефектът му + Write the document as a file in its own format Записване на документа като файл в собствен формат + Show its progress, see Cancel and write log lines Показване на напредъка, разпознаване на отказ и записване на редове в дневника # Actions panel: action sets (#3712). New Set… Нов набор… New Set Нов набор @@ -5680,6 +5725,19 @@ axis Y Axis Y ос Action Options… Опции на действието… Action Options Опции на действието Playback Options… Опции за възпроизвеждане… + Insert Stop… Вмъкване на спиране… + Insert Stop Вмъкване на спиране + Record Stop Записване на спиране + Allow Continue Разрешаване на продължаване + Continue Продължаване + Continue Action Продължаване на действието + Delete Action Step Изтриване на стъпка от действието + Press Play Current Selection in the Actions panel to go on. За да продължите, натиснете „Изпълнение на текущия избор“ в панела „Действия“. + Stop: {message} Спиране: {message} +@msg no action is waiting at a stop няма действие, което чака при спиране +@msg message must be text message трябва да бъде текст +@msg allowContinue must be true or false allowContinue трябва да бъде true или false +@msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране Playback Options Опции за възпроизвеждане Accelerated Ускорено Step by Step Стъпка по стъпка @@ -6084,6 +6142,7 @@ breakText Glyphs Глифи Gap 5 Разстояние 5 @msg {_1}: they came in as the page's art {_1}: те са внесени като изображението от страницата +@msg warped text objects that came in as drawn art, not as editable type: {_1} изкривени текстови обекти, внесени като изображение, а не като редактируем текст: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} обектите, изобразени различно от собствената страница на файла, са внесени като изображението от страницата: {_1} @msg You must choose a side pattern. Трябва да изберете страничен шаблон. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a8db1ebea..ca52bf517 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -448,6 +448,11 @@ Document Raster Effects Settings… Postavke rasterskih efekata dokumenta… Vector Effects Vektorski efekti 3D and Materials 3D i materijali + Plane Ravan + Extrude Istiskivanje + Revolve Rotaciono tijelo + Inflate Napuhni + Select objects, then pick an object type to give them a 3D effect. Odaberite objekte, zatim odaberite vrstu objekta da im date 3D efekat. Extrude & Bevel… Istiskivanje i zakošenje… Revolve… Rotaciono tijelo… Inflate… Napuhni… @@ -809,6 +814,9 @@ Put back in the toolbar Vrati u traku s alatima Float Panel Odvoji panel Dock Panel Usidri panel + Dock to Top Usidri na vrh + Dock to Bottom Usidri na dno + Dock Control Bar Usidri kontrolnu traku Float Tool Group Odvoji grupu alata Services Usluge Hide VectorCraft Sakrij VectorCraft @@ -938,6 +946,8 @@ lighting Light Svjetlo Expand Layers Panel Rows Proširi redove panela slojeva Export CSS… Izvezi CSS… Export Keyboard Shortcuts… Izvezi prečice na tastaturi… + Export Keyboard Shortcuts as Text… Izvezi prečice na tastaturi kao tekst… + Export Text… Izvezi tekst… File Info Dialog Dijalog informacija o datoteci Go to Artboard Idi na radnu ploču First Artboard Prva radna ploča @@ -1288,6 +1298,7 @@ lighting Light Svjetlo Rotate Tool Alat za rotaciju Rotate View Tool Alat za rotaciju prikaza Trackpad Gesture to Rotate View Pokret dodirne ploče za rotaciju prikaza + Hold a Tool Shortcut to Use the Tool Temporarily Držite prečicu alata za privremenu upotrebu alata Rotate View Rotiraj prikaz Rounded Rectangle Tool Alat zaobljeni pravougaonik Scale Tool Alat za skaliranje @@ -1610,6 +1621,7 @@ digits Malayalam Malajalam Step Backward Korak unazad Step Forward Korak naprijed Clear History Očisti historiju + New Document from State Novi dokument iz stanja Set History Limit… Postavi ograničenje historije… Open a document to see its history. Otvorite dokument da vidite njegovu historiju. Apply Data Set Primijeni skup podataka @@ -2055,6 +2067,14 @@ digits Malayalam Malajalam Location Lokacija No images Nema slika Relink to Folder… Ponovo poveži s folderom… + Each Link Separately Svaku vezu zasebno + Selected Instances Odabrane instance + All Instances Sve instance + Missing Links from This Folder Nedostajuće veze iz ovog foldera +@msg scope {v} is not each|selected|all|missingInFolder scope {v} nije each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths idu zajedno: paths navodi unose s ids i path, umjesto ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) unos {i} u paths treba path i ids (ID-ove objekata koje nema nijedan drugi unos) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder uzima path: u folderu te datoteke traže se ostale nedostajuće veze Show Missing Prikaži nedostajuće Sort by Status Sortiraj po statusu Clear Overrides Očisti zamjene @@ -2556,6 +2576,8 @@ digits Malayalam Malajalam Show Cross Hairs Prikaži končanicu Show Video Safe Areas Prikaži sigurne oblasti za video Video Ruler Pixel Aspect Ratio: Omjer stranica piksela video ravnala: + Fade Region Outside Artboard Zatamni područje izvan radne ploče + Update While Dragging Ažuriraj tokom povlačenja Hide Video Rulers Sakrij video ravnala Client-side (.html) Na strani klijenta (.html) Color Reduction: Smanjenje boja: @@ -3160,6 +3182,7 @@ theme Appearance Izgled Redefine Symbol Ponovo definiši simbol Remove Brush Stroke Ukloni potez četkicom Replace Symbol Zamijeni simbol + Reset Transformation Vrati transformaciju na početno Select All Instances Odaberi sve instance Symbol Sprayer Raspršivač simbola Symbolism Tool Alat simbolizam @@ -3630,6 +3653,8 @@ theme Appearance Izgled Real-time Drawing and Editing Crtanje i uređivanje u stvarnom vremenu Render Threads (-1 = Automatic) Niti iscrtavanja (-1 = automatski) Reset Preferences Vrati postavke na početne + Search Preferences Pretraga postavki + No settings found Nema pronađenih postavki Scale Corners Skaliraj uglove Scale Cursor Proportional to UI Skaliraj kursor proporcionalno interfejsu Scale Strokes & Effects Skaliraj poteze i efekte @@ -3650,6 +3675,7 @@ theme Appearance Izgled Snapping Tolerance Tolerancija hvatanja Spacing Guides Vodilice razmaka Invoke Distance Guides Pozovi vodiče udaljenosti + Snap to Last Location Prilijepi uz posljednju lokaciju Spanish Španski Subdivisions Podjele Templates Folder Folder predložaka @@ -3817,6 +3843,8 @@ axis Both Obje Center Sredina Transform in Perspective Transformiši u perspektivi Grayscale Nijanse sive + High Color Mnogo boja + Low Color Malo boja Embed Ugradi Star Zvijezda Power Saving (integrated) Ušteda energije (integrisana) @@ -4635,6 +4663,7 @@ imageTrace Outline Obris @msg nothing to save: the document has no visible art nema ničeg za čuvanje: dokument nema vidljiv crtež @msg nothing to undo nema ničeg za poništavanje @msg no history to clear nema historije za brisanje +@msg no history state {v} nema stanja historije {v} @msg nothing to warp nema ničeg za iskrivljenje @msg nothing to wrap nema ničeg za omotavanje @msg object {0} cannot have children objekat {0} ne može imati djecu @@ -4881,6 +4910,7 @@ imageTrace Outline Obris @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} sliku nije moguće dekodirati: prikazuje se kao prekriženi okvir|{n} slike nije moguće dekodirati: prikazuju se kao prekriženi okviri|{n} slika nije moguće dekodirati: prikazuju se kao prekriženi okviri @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} povezana slika promijenjena je na disku: Ažuriraj veze prikazuje novu verziju|{n} povezane slike promijenjene su na disku: Ažuriraj veze prikazuje nove verzije|{n} povezanih slika promijenjeno je na disku: Ažuriraj veze prikazuje nove verzije @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Ažurirana je {n} povezana datoteka promijenjena na disku|Ažurirane su {n} povezane datoteke promijenjene na disku|Ažurirano je {n} povezanih datoteka promijenjenih na disku +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokument zadaje napust koji ova PDF/X datoteka izostavlja: uključite „Koristi postavke napusta dokumenta“ @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Izvezeno {what} s {n} bilješkom: {first}|Izvezeno {what} s {n} bilješke: {first}|Izvezeno {what} s {n} bilješki: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} datoteka fonta sada je u {folder}|{n} datoteke fontova sada su u {folder}|{n} datoteka fontova sada je u {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} datoteka fonta sada je u {folder}; {failed} nije moguće dodati: {reason}|{n} datoteke fontova sada su u {folder}; {failed} nije moguće dodati: {reason}|{n} datoteka fontova sada je u {folder}; {failed} nije moguće dodati: {reason} @@ -4958,6 +4988,7 @@ imageTrace Outline Obris @msg No linked files to look for Nema povezanih datoteka za traženje @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je referenca na korak {r}, koju razrješavaju samo `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` mora biti niz ID-ova objekata, a ne {v} +@msg `colors` must be an array of colors, not {v} `colors` mora biti niz boja, a ne {v} @msg `corners` must be an array of anchor indices, not {v} `corners` mora biti niz indeksa sidara, a ne {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: rezultat koraka {i} nema takvu vrijednost (napišite $$ za doslovni $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: prije ovog nema koraka {i} (koraci se broje od 0; napišite $$ za doslovni $) @@ -5696,6 +5727,33 @@ axis Y Axis Y osa @msg a live effect runs while the document is drawn, so it can't be `heavy` živi efekat radi dok se dokument iscrtava, pa ne može biti `heavy` @msg an exporter always gets an export's budget: leave `budget` out izvoznik uvijek dobija budžet izvoza: izostavite `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): instalirajte ga s `allowHeavy: true` kada korisnik pristane +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instalirati „{name}”, koji traži ove dozvole? + ID: {id} ID: {id} + Version: {version} Verzija: {version} + File SHA-256: {hash} SHA-256 datoteke: {hash} + Read the objects it is given Čitati objekte koje dobije + Change, add and delete objects in the document Mijenjati, dodavati i brisati objekte u dokumentu + Keep data of its own with the document Čuvati vlastite podatke uz dokument + Change how the objects its effect is applied to are drawn Mijenjati kako se crtaju objekti na koje je primijenjen njegov efekt + Write the document as a file in its own format Zapisati dokument kao datoteku u vlastitom formatu + Show its progress, see Cancel and write log lines Prikazati napredak, prepoznati otkazivanje i zapisivati redove dnevnika +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `progress` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modul uvozi `env.{name}` s drugim tipom od domaćinovog: domaćinov tip je {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; ovaj domaćin podržava nivoe API-ja dodataka od 1 do {API_LEVEL} +@msg `grant` must be a list of permission names `grant` mora biti lista naziva dozvola +@msg `grant` names an unknown permission `{shown}` `grant` navodi nepoznatu dozvolu `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` dodatku vrste `{_1}` potrebna je dozvola `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` najviše {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` dozvola `{shown}` ne odnosi se na dodatak vrste `{_1}` +@msg permission `{shown}` is listed twice dozvola `{shown}` navedena je dvaput +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it dozvola `{shown}` rezervisana je za kasniji API dodataka; ovaj domaćin je ne dodjeljuje +@msg unknown permission `{shown}` nepoznata dozvola `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” traži dozvole {asked}: instalirajte ga s `grant` koji ih navodi kada korisnik pristane +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nije dobio `{p}`, što je potrebno njegovom izlazu: ništa nije promijenjeno +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nema SHA-256 naveden u `hash`: ništa nije instalirano New Set… Novi skup… New Set Novi skup Set Options… Opcije skupa… @@ -5703,6 +5761,19 @@ axis Y Axis Y osa Action Options… Opcije akcije… Action Options Opcije akcije Playback Options… Opcije reprodukcije… + Insert Stop… Umetni zaustavljanje… + Insert Stop Umetni zaustavljanje + Record Stop Snimi zaustavljanje + Allow Continue Dozvoli nastavak + Continue Nastavi + Continue Action Nastavi akciju + Delete Action Step Izbriši korak akcije + Press Play Current Selection in the Actions panel to go on. Za nastavak pritisnite „Reprodukuj trenutni odabir“ na panelu Akcije. + Stop: {message} Zaustavljanje: {message} +@msg no action is waiting at a stop nijedna akcija ne čeka na zaustavljanju +@msg message must be text message mora biti tekst +@msg allowContinue must be true or false allowContinue mora biti true ili false +@msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem Playback Options Opcije reprodukcije Accelerated Ubrzano Step by Step Korak po korak @@ -6118,6 +6189,7 @@ shape Open Otvoren @msg the paths have nothing to merge there putanje tu nemaju šta spojiti @msg the path is locked or hidden putanja je zaključana ili skrivena @msg {_1}: they came in as the page's art {_1}: uvezeni su kao ilustracija stranice +@msg warped text objects that came in as drawn art, not as editable type: {_1} iskrivljeni tekstualni objekti uvezeni kao ilustracija, a ne kao tekst koji se može uređivati: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objekti koji su se iscrtali drugačije od vlastite stranice datoteke uvezeni su kao ilustracija stranice: {_1} @msg You must choose a side pattern. Morate odabrati bočni uzorak. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3898a677f..ea246b35c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -107,6 +107,11 @@ 32 bit 32 bits 32 bits/pixel 32 bits/píxel 3D and Materials 3D i materials + Plane Pla + Extrude Extrusió + Revolve Revolució + Inflate Infla + Select objects, then pick an object type to give them a 3D effect. Seleccioneu objectes i després trieu un tipus d’objecte per donar-los un efecte 3D. 3D Lighting Preset Preajust d’il·luminació 3D 3D Ribbon Cinta 3D 4 bit 4 bits @@ -652,6 +657,7 @@ Clear Events Esborra els esdeveniments Clear Guides Esborra les guies Clear History Esborra l’historial + New Document from State Document nou a partir de l’estat Clear Overrides Esborra les modificacions Clear Recent Files Esborra els fitxers recents Click Fes clic @@ -1165,6 +1171,9 @@ Do Not Scale No escalis Do you want to save the changes you made to “{name}”? Voleu desar els canvis que heu fet a “{name}”? Dock Panel Acobla el panell + Dock to Top Acobla a dalt + Dock to Bottom Acobla a baix + Dock Control Bar Acobla la barra de control Document Document Document Color Mode Mode de color del document Document Info Informació del document @@ -1466,6 +1475,8 @@ Export for Screens Settings Configuració d’exportació per a pantalles Export for Screens… Exporta per a pantalles… Export Keyboard Shortcuts… Exporta les dreceres de teclat… + Export Keyboard Shortcuts as Text… Exporta les dreceres de teclat com a text… + Export Text… Exporta el text… Export Layer Exporta la capa Export Library Exporta la biblioteca Export Library… Exporta la biblioteca… @@ -1772,6 +1783,8 @@ digits Malayalam Malaiàlam Grays Grisos Grays and Neutrals Grisos i neutres Grayscale Escala de grisos + High Color Molts colors + Low Color Pocs colors Grayscale images Imatges en escala de grisos Greek & Cyrillic Grec i ciríl·lic Green Verd @@ -2029,6 +2042,7 @@ digits Malayalam Malaiàlam Invert Pie Inverteix el diagrama circular Inverted Round Rodó invertit Invoke Distance Guides Invoca les guies de distància + Snap to Last Location Ajusta a l’última ubicació Isolate Aïlla Isolate Blending Aïlla la fusió Isolate Group Aïlla el grup @@ -3311,6 +3325,14 @@ digits Malayalam Malaiàlam Relink Torna a enllaçar Relink Linked Files to the Document Torna a enllaçar els fitxers enllaçats al document Relink to Folder… Torna a enllaçar a una carpeta… + Each Link Separately Cada enllaç per separat + Selected Instances Instàncies seleccionades + All Instances Totes les instàncies + Missing Links from This Folder Enllaços que falten d’aquesta carpeta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} no és each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths van junts: paths llista entrades amb ids i path, en lloc d’ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) l’entrada {i} de paths necessita path i ids (identificadors d’objecte que cap altra entrada no tingui) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder pren path: a la carpeta d’aquest fitxer es cerquen els altres enllaços que falten Relink… Torna a enllaçar… Reload Plug-ins Recarrega els complements Remember Import Matches Recorda les coincidències d’importació @@ -3369,6 +3391,7 @@ digits Malayalam Malaiàlam 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 Replace Symbol Reemplaça el símbol + Reset Transformation Restableix la transformació Replace With Font Reemplaça per un tipus de lletra Replace With Font From Reemplaça per un tipus de lletra de System Sistema @@ -3396,6 +3419,8 @@ digits Malayalam Malaiàlam Reset Painting Restableix Pintura Reset Panel Restableix el panell Reset Preferences Restableix les preferències + Search Preferences Cerca a les preferències + No settings found No s'ha trobat cap paràmetre Reset Printing and Proofing Restableix impressió i prova Reset Profiles Restableix els perfils Reset Rotate View Restableix la rotació de la vista @@ -3803,6 +3828,19 @@ digits Malayalam Malaiàlam Action Options… Opcions de l’acció… Action Options Opcions de l’acció Playback Options… Opcions de reproducció… + Insert Stop… Insereix una aturada… + Insert Stop Insereix una aturada + Record Stop Enregistra una aturada + Allow Continue Permet continuar + Continue Continua + Continue Action Continua l’acció + Delete Action Step Suprimeix el pas de l’acció + Press Play Current Selection in the Actions panel to go on. Per continuar, premeu «Reprodueix la selecció actual» al tauler Accions. + Stop: {message} Aturada: {message} +@msg no action is waiting at a stop cap acció no espera en una aturada +@msg message must be text message ha de ser text +@msg allowContinue must be true or false allowContinue ha de ser true o false +@msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada Playback Options Opcions de reproducció Accelerated Accelerada Step by Step Pas a pas @@ -4399,6 +4437,7 @@ digits Malayalam Malaiàlam Tracing Result with Outlines Resultat del traçat amb contorns Tracking Espaiat Trackpad Gesture to Rotate View Gesticulació del trackpad per girar la vista + Hold a Tool Shortcut to Use the Tool Temporarily Mantén premuda la drecera d’una eina per usar-la temporalment Trademark Symbol Símbol de marca comercial Transform Transforma Transform Again Transforma de nou @@ -4658,6 +4697,8 @@ digits Malayalam Malaiàlam Video 3840×2160 Vídeo 3840×2160 Video Banner 2560×1440 Bàner de vídeo 2560×1440 Video Ruler Pixel Aspect Ratio: Relació d’aspecte de píxel de les regles de vídeo: + Fade Region Outside Artboard Enfosqueix la regió fora del tauler + Update While Dragging Actualitza mentre s’arrossega Video Thumbnail 1280×720 Miniatura de vídeo 1280×720 View Visualitza View Artboard Only Visualitza només el tauler @@ -4835,7 +4876,7 @@ digits Malayalam Malaiàlam @msg `Custom` stands for changed settings: save the preset under another name `Custom` representa una configuració modificada: deseu el preajust amb un altre nom @msg `dither` applies to gradient fills (paint.setFill) `dither` s’aplica als farciments de degradat (paint.setFill) @msg `dither` applies to gradient fills (stroke: false) `dither` s’aplica als farciments de degradat (stroke: false) -@msg `dither` must be true or false `dither` ha de ser cert o fals +@msg `dither` must be true or false `dither` ha de ser true o false @msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` té {size} bytes (límit {MAX_DOCUMENT_DATA}) @msg `freeform.lines` must be an array `freeform.lines` ha de ser una matriu @msg `freeform.points` must be an array `freeform.points` ha de ser una matriu @@ -4843,6 +4884,7 @@ digits Malayalam Malaiàlam @msg `groupColors` must be an array `groupColors` ha de ser una matriu @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` és la referència del pas {r}, que només resolen `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` ha de ser una matriu d’identificadors d’objecte, no {v} +@msg `colors` must be an array of colors, not {v} `colors` ha de ser una matriu de colors, no {v} @msg `library` is a library id or name (see {list}) `library` és un identificador o un nom de biblioteca (vegeu {list}) @msg `method` must be classic or perceptual `method` ha de ser classic o perceptual @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` i `maxAnchors` han de ser nombres enters @@ -4854,7 +4896,7 @@ digits Malayalam Malaiàlam @msg `preserve` must be an object `preserve` ha de ser un objecte @msg `globalAdjust` must be an object `globalAdjust` ha de ser un objecte @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 cert o fals +@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 `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" @@ -4881,7 +4923,7 @@ digits Malayalam Malaiàlam @msg `{key}` must be an array of these names: {_1} `{key}` ha de ser una matriu d’aquests noms: {_1} @msg `{key}` must be between {min} and {max} (got {x}) `{key}` ha d’estar entre {min} i {max} (rebut {x}) @msg `{key}` must be one of {_1} `{key}` ha de ser un de {_1} -@msg `{key}` must be true or false `{key}` ha de ser cert o fals +@msg `{key}` must be true or false `{key}` ha de ser true o false @msg `{k}` must be [x, y] `{k}` ha de ser [x, y] @msg `{min_key}` is greater than `{max_key}` `{min_key}` és més gran que `{max_key}` @msg `{name}` has no options `{name}` no té opcions @@ -5034,7 +5076,7 @@ digits Malayalam Malaiàlam @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` no és un color com "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: com a màxim {MAX_TABLE} colors @msg column docks a set as a dock column, with no anchor or icon column acobla un conjunt com a columna d’acoblament, sense àncora ni icona -@msg column must be true or a number from 1 column ha de ser cert o un nombre a partir d’1 +@msg column must be true or a number from 1 column ha de ser true o un nombre a partir d’1 @msg Combine Slices: the slices have no bounds Combina els talls: els talls no tenen límits @msg command `{0}` is not available right now: {1} l’ordre `{0}` no està disponible ara mateix: {1} @msg compound paths can only contain paths els traçats compostos només poden contenir traçats @@ -5271,6 +5313,7 @@ digits Malayalam Malaiàlam @msg no graphic style `{name}` no hi ha l’estil gràfic `{name}` @msg no graphic style `{n}` no hi ha l’estil gràfic `{n}` @msg no history to clear no hi ha historial per esborrar +@msg no history state {v} no hi ha cap estat de l’historial {v} @msg no interaction in progress no hi ha cap interacció en curs @msg no key object no hi ha objecte clau @msg no library `{id}` no hi ha la biblioteca `{id}` @@ -5461,6 +5504,7 @@ digits Malayalam Malaiàlam @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes no s’ha pogut descodificar {n} imatge i es mostra com a caixa creuada|no s’han pogut descodificar {n} imatges i es mostren com a caixes creuades @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} imatge enllaçada ha canviat al disc: Actualitza els enllaços mostra la versió nova|{n} imatges enllaçades han canviat al disc: Actualitza els enllaços mostra les versions noves @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk S’ha actualitzat {n} fitxer enllaçat que ha canviat al disc|S’han actualitzat {n} fitxers enllaçats que han canviat al disc +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings el document defineix un sangrat que aquest fitxer PDF/X omet: activeu «Utilitza la configuració de sangrat del document» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} S’ha exportat {what} amb {n} nota: {first}|S’ha exportat {what} amb {n} notes: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} fitxer de tipus de lletra ja és a {folder}|{n} fitxers de tipus de lletra ja són a {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} fitxer de tipus de lletra ja és a {folder}; {failed} no s’han pogut afegir: {reason}|{n} fitxers de tipus de lletra ja són a {folder}; {failed} no s’han pogut afegir: {reason} @@ -5798,6 +5842,33 @@ digits Malayalam Malaiàlam @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): instal·la’l amb `allowHeavy: true` quan l’usuari hi estigui d’acord +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos? + ID: {id} ID: {id} + Version: {version} Versió: {version} + File SHA-256: {hash} SHA-256 del fitxer: {hash} + Read the objects it is given Llegir els objectes que rep + Change, add and delete objects in the document Canviar, afegir i suprimir objectes del document + Keep data of its own with the document Desar dades pròpies amb el document + Change how the objects its effect is applied to are drawn Canviar com es dibuixen els objectes als quals s’aplica el seu efecte + Write the document as a file in its own format Escriure el document com un fitxer en un format propi + Show its progress, see Cancel and write log lines Mostrar el progrés, detectar la cancel·lació i escriure línies de registre +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `progress` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} el mòdul importa `env.{name}` amb un tipus diferent del de l’amfitrió: el de l’amfitrió és {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` és {_1}; aquest amfitrió admet els nivells d’API de complements de l’1 al {API_LEVEL} +@msg `grant` must be a list of permission names `grant` ha de ser una llista de noms de permisos +@msg `grant` names an unknown permission `{shown}` `grant` esmenta un permís desconegut `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un complement del tipus `{_1}` necessita el permís `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` com a màxim {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permís `{shown}` no s’aplica a un complement del tipus `{_1}` +@msg permission `{shown}` is listed twice el permís `{shown}` apareix dues vegades +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix +@msg unknown permission `{shown}` permís desconegut `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res +@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 @msg “{name}” is not a file «{name}» no és un fitxer axis Both Tots dos axis Horizontal Horitzontal @@ -6218,6 +6289,7 @@ breakText Glyphs Glifs Gap 5 Separació 5 @msg {_1}: they came in as the page's art {_1}: han entrat com a art de la pàgina +@msg warped text objects that came in as drawn art, not as editable type: {_1} objectes de text deformats que han entrat com a art, no com a text editable: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} els objectes que es dibuixaven diferent de la pàgina pròpia del fitxer han entrat com a art de la pàgina: {_1} @msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eff1d2d34..7e51de244 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -456,6 +456,11 @@ Document Raster Effects Settings… Nastavení rastrových efektů dokumentu… Vector Effects Vektorové efekty 3D and Materials 3D a materiály + Plane Rovina + Extrude Vysunutí + Revolve Rotační těleso + Inflate Nafouknutí + Select objects, then pick an object type to give them a 3D effect. Vyberte objekty a pak zvolte typ objektu, abyste jim dali 3D efekt. Extrude & Bevel… Vysunutí a zkosení… Revolve… Rotační těleso… Inflate… Nafouknutí… @@ -819,6 +824,9 @@ Put back in the toolbar Vrátit do panelu nástrojů Float Panel Plovoucí panel Dock Panel Ukotvit panel + Dock to Top Ukotvit nahoru + Dock to Bottom Ukotvit dolů + Dock Control Bar Ukotvit ovládací panel Float Tool Group Plovoucí skupina nástrojů # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. @@ -957,6 +965,8 @@ lighting Light Světlo Expand Layers Panel Rows Rozbalit řádky panelu Vrstvy Export CSS… Exportovat CSS… Export Keyboard Shortcuts… Exportovat klávesové zkratky… + Export Keyboard Shortcuts as Text… Exportovat klávesové zkratky jako text… + Export Text… Exportovat text… File Info Dialog Dialogové okno Informace o souboru Go to Artboard Přejít na pracovní plochu First Artboard První pracovní plocha @@ -1309,6 +1319,7 @@ lighting Light Světlo Rotate Tool Nástroj Otočení Rotate View Tool Nástroj Otočení zobrazení Trackpad Gesture to Rotate View Otáčet zobrazení gestem na trackpadu + Hold a Tool Shortcut to Use the Tool Temporarily Podržením klávesové zkratky nástroj dočasně použít Rotate View Otočení zobrazení Rounded Rectangle Tool Nástroj Zaoblený obdélník Scale Tool Nástroj Změna velikosti @@ -1634,6 +1645,7 @@ digits Malayalam Malajálamské Step Backward Krok zpět Step Forward Krok vpřed Clear History Vymazat historii + New Document from State Nový dokument ze stavu Set History Limit… Nastavit limit historie… Open a document to see its history. Otevřete dokument, abyste viděli jeho historii. # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2081,6 +2093,14 @@ digits Malayalam Malajálamské Location Umístění No images Žádné obrázky Relink to Folder… Znovu propojit ke složce… + Each Link Separately Každé propojení zvlášť + Selected Instances Vybrané instance + All Instances Všechny instance + Missing Links from This Folder Chybějící propojení z této složky +@msg scope {v} is not each|selected|all|missingInFolder scope {v} není each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each a paths patří k sobě: paths uvádí položky s ids a path místo ids, path a folder +@msg paths entry {i} needs path and ids (object ids no other entry has) položka {i} v paths potřebuje path a ids (ID objektů, které nemá žádná jiná položka) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder bere path: ve složce tohoto souboru se hledají ostatní chybějící propojení Show Missing Zobrazit chybějící Sort by Status Seřadit podle stavu Clear Overrides Vymazat přepsání @@ -2586,6 +2606,8 @@ digits Malayalam Malajálamské Show Cross Hairs Zobrazit nitkový kříž Show Video Safe Areas Zobrazit bezpečné oblasti videa Video Ruler Pixel Aspect Ratio: Poměr stran pixelu video pravítek: + Fade Region Outside Artboard Ztmavit oblast mimo pracovní plochu + Update While Dragging Aktualizovat při tažení Hide Video Rulers Skrýt video pravítka Client-side (.html) Na straně klienta (.html) Color Reduction: Redukce barev: @@ -3199,6 +3221,7 @@ theme Appearance Motiv Redefine Symbol Předefinovat symbol Remove Brush Stroke Odebrat tah štětcem Replace Symbol Nahradit symbol + Reset Transformation Obnovit transformaci Select All Instances Vybrat všechny instance Symbol Sprayer Rozprašovač symbolů Symbolism Tool Nástroj symbolů @@ -3226,6 +3249,9 @@ theme Appearance Motiv Convert Document Color Mode Převést barevný režim dokumentu Gamut Warning Upozornění na gamut Load Color Profile… Načíst barevný profil… + Load Character Styles… Načíst styly znaků… + Load Paragraph Styles… Načíst styly odstavců… + Load All Styles… Načíst všechny styly… Remove Color Profile… Odebrat barevný profil… Plates Desky Spot Color Přímá barva @@ -3671,6 +3697,8 @@ theme Appearance Motiv Real-time Drawing and Editing Kreslení a úpravy v reálném čase Render Threads (-1 = Automatic) Vlákna vykreslování (-1 = automaticky) Reset Preferences Obnovit předvolby + Search Preferences Hledat v předvolbách + No settings found Nenalezena žádná nastavení Scale Corners Měnit velikost rohů Scale Cursor Proportional to UI Měnit velikost kurzoru úměrně rozhraní Scale Strokes & Effects Měnit velikost tahů a efektů @@ -3691,6 +3719,7 @@ theme Appearance Motiv Snapping Tolerance Tolerance přichytávání Spacing Guides Vodítka rozestupů Invoke Distance Guides Vyvolat vodítka vzdálenosti + Snap to Last Location Přichytávat k poslednímu umístění Spanish Španělština Subdivisions Dělení Templates Folder Složka šablon @@ -3858,6 +3887,8 @@ axis Both Obě Center Střed Transform in Perspective Transformovat v perspektivě Grayscale Stupně šedi + High Color Mnoho barev + Low Color Málo barev Embed Vložit Star Hvězda Power Saving (integrated) Úsporný režim (integrovaná) @@ -4682,6 +4713,7 @@ imageTrace Outline Obrys @msg nothing to save: the document has no visible art není co uložit: dokument nemá žádnou viditelnou kresbu @msg nothing to undo není co vrátit @msg no history to clear není žádná historie k vymazání +@msg no history state {v} žádný stav historie {v} @msg nothing to warp není co pokřivit @msg nothing to wrap není co obtékat @msg object {0} cannot have children objekt {0} nemůže mít potomky @@ -4930,6 +4962,7 @@ imageTrace Outline Obrys @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} obrázek nelze dekódovat: zobrazuje se jako přeškrtnutý rámeček|{n} obrázky nelze dekódovat: zobrazují se jako přeškrtnuté rámečky|{n} obrázků nelze dekódovat: zobrazují se jako přeškrtnuté rámečky @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions Na disku se změnil {n} propojený obrázek: novou verzi zobrazí Aktualizovat propojení|Na disku se změnily {n} propojené obrázky: nové verze zobrazí Aktualizovat propojení|Na disku se změnilo {n} propojených obrázků: nové verze zobrazí Aktualizovat propojení @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Aktualizován {n} propojený soubor změněný na disku|Aktualizovány {n} propojené soubory změněné na disku|Aktualizováno {n} propojených souborů změněných na disku +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokument nastavuje spadávku, kterou tento soubor PDF/X vynechává: zapněte „Použít nastavení spadávky dokumentu“ @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Exportováno: {what}, {n} poznámka: {first}|Exportováno: {what}, {n} poznámky: {first}|Exportováno: {what}, {n} poznámek: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} soubor písma je nyní ve složce {folder}|{n} soubory písem jsou nyní ve složce {folder}|{n} souborů písem je nyní ve složce {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} soubor písma je nyní ve složce {folder}; nepodařilo se přidat {failed}: {reason}|{n} soubory písem jsou nyní ve složce {folder}; nepodařilo se přidat {failed}: {reason}|{n} souborů písem je nyní ve složce {folder}; nepodařilo se přidat {failed}: {reason} @@ -5016,6 +5049,7 @@ imageTrace Outline Obrys @msg No linked files to look for Žádné propojené soubory k vyhledání @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} +@msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v} @msg `corners` must be an array of anchor indices, not {v} `corners` musí být pole indexů kotevních bodů, ne {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: výsledek kroku {i} takovou hodnotu nemá (pro samotný znak $ napište $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: před tímto krokem není krok {i} (kroky se číslují od 0; pro samotný znak $ napište $$) @@ -5788,6 +5822,33 @@ axis Y Axis osa Y @msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy` @msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění? + ID: {id} ID: {id} + Version: {version} Verze: {version} + File SHA-256: {hash} SHA-256 souboru: {hash} + Read the objects it is given Číst objekty, které dostane + Change, add and delete objects in the document Měnit, přidávat a mazat objekty v dokumentu + Keep data of its own with the document Ukládat vlastní data s dokumentem + Change how the objects its effect is applied to are drawn Měnit, jak se kreslí objekty, na které je použit jeho efekt + Write the document as a file in its own format Zapsat dokument jako soubor ve vlastním formátu + Show its progress, see Cancel and write log lines Zobrazit průběh, rozpoznat zrušení a zapisovat řádky protokolu +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `progress` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modul importuje `env.{name}` s jiným typem, než má hostitel: typ hostitele je {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen `env.vc_progress`, `env.vc_cancelled` a `env.vc_log` (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; tento hostitel podporuje úrovně API zásuvných modulů 1 až {API_LEVEL} +@msg `grant` must be a list of permission names `grant` musí být seznam názvů oprávnění +@msg `grant` names an unknown permission `{shown}` `grant` uvádí neznámé oprávnění `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` zásuvný modul druhu `{_1}` potřebuje oprávnění `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` nejvýše {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` oprávnění `{shown}` se nevztahuje na zásuvný modul druhu `{_1}` +@msg permission `{shown}` is listed twice oprávnění `{shown}` je uvedeno dvakrát +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje +@msg unknown permission `{shown}` neznámé oprávnění `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo +@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 # Actions panel: action sets (#3712). New Set… Nová sada… New Set Nová sada @@ -5796,6 +5857,19 @@ axis Y Axis osa Y Action Options… Možnosti akce… Action Options Možnosti akce Playback Options… Možnosti přehrávání… + Insert Stop… Vložit zastavení… + Insert Stop Vložit zastavení + Record Stop Zaznamenat zastavení + Allow Continue Povolit pokračování + Continue Pokračovat + Continue Action Pokračovat v akci + Delete Action Step Odstranit krok akce + Press Play Current Selection in the Actions panel to go on. Chcete-li pokračovat, stiskněte na panelu Akce tlačítko Přehrát aktuální výběr. + Stop: {message} Zastavení: {message} +@msg no action is waiting at a stop žádná akce nečeká na zastavení +@msg message must be text message musí být text +@msg allowContinue must be true or false allowContinue musí být true nebo false +@msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením Playback Options Možnosti přehrávání Accelerated Zrychleně Step by Step Krok za krokem @@ -6234,6 +6308,7 @@ breakText Glyphs Glyfy Gap 5 Mezera 5 @msg {_1}: they came in as the page's art {_1}: byly načteny jako kresba ze stránky +@msg warped text objects that came in as drawn art, not as editable type: {_1} pokřivené textové objekty načtené jako kresba, ne jako upravitelný text: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objekty, které se vykreslily jinak než vlastní stránka souboru, byly načteny jako kresba ze stránky: {_1} @msg You must choose a side pattern. Musíte zvolit boční vzorek. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index abfe581e7..84134c893 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -414,6 +414,11 @@ Document Raster Effects Settings… Indstillinger for rastereffekter i dokumentet… Vector Effects Vektoreffekter 3D and Materials 3D og materialer + Plane Plan + Extrude Ekstrudér + Revolve Omdrej + Inflate Pust op + Select objects, then pick an object type to give them a 3D effect. Markér objekter, og vælg derefter en objekttype for at give dem en 3D-effekt. Extrude & Bevel… Ekstrudér og facet… Revolve… Omdrej… Inflate… Pust op… @@ -753,6 +758,9 @@ Put back in the toolbar Sæt tilbage på værktøjslinjen Float Panel Flydende panel Dock Panel Dok panel + Dock to Top Dok øverst + Dock to Bottom Dok nederst + Dock Control Bar Dok kontrollinjen Float Tool Group Flydende værktøjsgruppe Services Tjenester Hide VectorCraft Skjul VectorCraft @@ -878,6 +886,8 @@ lighting Light Lys Expand Layers Panel Rows Udvid rækker i lagpanelet Export CSS… Eksportér CSS… Export Keyboard Shortcuts… Eksportér tastaturgenveje… + Export Keyboard Shortcuts as Text… Eksportér tastaturgenveje som tekst… + Export Text… Eksportér tekst… File Info Dialog Dialogboks til filoplysninger Go to Artboard Gå til tegnebræt Graphic Style Options… Indstillinger for grafisk typografi… @@ -1222,6 +1232,7 @@ lighting Light Lys Rotate Tool Roteringsværktøj Rotate View Tool Visningsroteringsværktøj Trackpad Gesture to Rotate View Rotér visning med bevægelse på pegefeltet + Hold a Tool Shortcut to Use the Tool Temporarily Hold et værktøjs genvejstast nede for at bruge værktøjet midlertidigt Rotate View Rotér visning Rounded Rectangle Tool Afrundet rektangelværktøj Scale Tool Skaleringsværktøj @@ -1544,6 +1555,7 @@ digits Malayalam Malayalam Step Backward Et trin tilbage Step Forward Et trin frem Clear History Ryd historik + New Document from State Nyt dokument fra tilstand Set History Limit… Angiv historikgrænse… Open a document to see its history. Åbn et dokument for at se dets historik. Abutting Stødende @@ -1887,6 +1899,14 @@ digits Malayalam Malayalam Location Placering No images Ingen billeder Relink to Folder… Genopret link til mappe… + Each Link Separately Hvert link for sig + Selected Instances Valgte forekomster + All Instances Alle forekomster + Missing Links from This Folder Manglende links fra denne mappe +@msg scope {v} is not each|selected|all|missingInFolder scope {v} er ikke each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each og paths hører sammen: paths angiver poster med ids og path i stedet for ids, path og folder +@msg paths entry {i} needs path and ids (object ids no other entry has) post {i} i paths skal have path og ids (objekt-id'er, som ingen anden post har) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder tager path: der søges efter de andre manglende links i den fils mappe Show Missing Vis manglende Sort by Status Sortér efter status Clear Overrides Ryd tilsidesættelser @@ -2374,6 +2394,8 @@ digits Malayalam Malayalam Show Cross Hairs Vis trådkors Show Video Safe Areas Vis sikre videoområder Video Ruler Pixel Aspect Ratio: Pixelformat for videolinealer: + Fade Region Outside Artboard Ton området uden for kunstpladen ned + Update While Dragging Opdater under trækning Hide Video Rulers Skjul videolinealer Client-side (.html) Klientbaseret (.html) Color Reduction: Farvereduktion: @@ -2943,6 +2965,7 @@ theme Appearance Udseende Redefine Symbol Omdefinér symbol Remove Brush Stroke Fjern penselstrøg Replace Symbol Erstat symbol + Reset Transformation Nulstil transformation Select All Instances Markér alle forekomster Symbol Sprayer Symbolsprøjter Symbolism Tool Symbolværktøj @@ -2970,6 +2993,9 @@ theme Appearance Udseende Convert Document Color Mode Konvertér dokumentets farvetilstand Gamut Warning Gamut-advarsel Load Color Profile… Indlæs farveprofil… + Load Character Styles… Indlæs tegnstile… + Load Paragraph Styles… Indlæs afsnitsstile… + Load All Styles… Indlæs alle stile… Plates Plader Spot Color Spotfarve Add to Swatches Føj til farveprøver @@ -3399,6 +3425,8 @@ theme Appearance Udseende Real-time Drawing and Editing Tegning og redigering i realtid Render Threads (-1 = Automatic) Renderingstråde (-1 = automatisk) Reset Preferences Nulstil indstillinger + Search Preferences Søg i præferencer + No settings found Ingen indstillinger fundet Scale Corners Skalér hjørner Scale Cursor Proportional to UI Skalér markør proportionalt med brugergrænsefladen Scale Strokes & Effects Skalér konturer & effekter @@ -3419,6 +3447,7 @@ theme Appearance Udseende Snapping Tolerance Snap-tolerance Spacing Guides Afstandshjælpelinjer Invoke Distance Guides Aktivér hjælpelinjer for afstandsmåling + Snap to Last Location Fastgør til sidste placering Spanish Spansk Subdivisions Underinddelinger Templates Folder Skabelonmappe @@ -3586,6 +3615,8 @@ axis Both Begge Center Midt Transform in Perspective Transformer i perspektiv Grayscale Gråtoner + High Color Mange farver + Low Color Få farver Embed Integrér Star Stjerne Power Saving (integrated) Strømbesparende (integreret) @@ -4337,6 +4368,7 @@ imageTrace Outline Kontur @msg nothing to save: the document has no visible art intet at gemme: dokumentet har ingen synlig grafik @msg nothing to undo intet at fortryde @msg no history to clear ingen historik at rydde +@msg no history state {v} ingen historiktilstand {v} @msg nothing to warp intet at forvride @msg nothing to wrap intet at ombryde @msg object {0} cannot have children objektet {0} kan ikke have underobjekter @@ -4562,6 +4594,7 @@ imageTrace Outline Kontur @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} billede kunne ikke afkodes og vises som en overkrydset boks|{n} billeder kunne ikke afkodes og vises som overkrydsede bokse @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} linket billede er ændret på disken: Opdatér links viser den nye version|{n} linkede billeder er ændret på disken: Opdatér links viser de nye versioner @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Opdaterede {n} linket fil ændret på disken|Opdaterede {n} linkede filer ændret på disken +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokumentet angiver et udløb, som denne PDF/X-fil udelader: slå „Brug dokumentets udløbsindstillinger“ til @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Eksporterede {what} med {n} note: {first}|Eksporterede {what} med {n} noter: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} skrifttypefil er nu i {folder}|{n} skrifttypefiler er nu i {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} skrifttypefil er nu i {folder}; {failed} kunne ikke tilføjes: {reason}|{n} skrifttypefiler er nu i {folder}; {failed} kunne ikke tilføjes: {reason} @@ -4644,6 +4677,7 @@ imageTrace Outline Kontur @msg No linked files to look for Ingen linkede filer at lede efter @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` er trinreferencen {r}, som kun `run` og `command_batch` fortolker @msg `ids` must be an array of object ids, not {v} `ids` skal være et array af objekt-id'er, ikke {v} +@msg `colors` must be an array of colors, not {v} `colors` skal være et array af farver, ikke {v} @msg `corners` must be an array of anchor indices, not {v} `corners` skal være et array af ankerindekser, ikke {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: resultatet af trin {i} har ingen sådan værdi (skriv $$ for at få et bogstaveligt $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: der findes intet trin {i} før dette (trin nummereres fra 0; skriv $$ for at få et bogstaveligt $) @@ -5687,6 +5721,17 @@ axis Y Axis Y-akse The plug-in is computing… Plug-in'en regner… Inspect Plug-in Undersøg plug-in Cancel Plug-in Run Annullér plug-in-kørsel +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installér »{name}«, som beder om disse rettigheder? + ID: {id} ID: {id} + Version: {version} Version: {version} + 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 + Keep data of its own with the document Gemme sine egne data med dokumentet + Change how the objects its effect is applied to are drawn Ændre, hvordan de objekter, dens effekt er anvendt på, tegnes + Write the document as a file in its own format Skrive dokumentet som en fil i sit eget format + Show its progress, see Cancel and write log lines Vise sin fremdrift, se en annullering og skrive logrækker # Actions panel: action sets (#3712). New Set… Nyt sæt… New Set Nyt sæt @@ -5695,6 +5740,19 @@ axis Y Axis Y-akse Action Options… Indstillinger for handling… Action Options Indstillinger for handling Playback Options… Afspilningsindstillinger… + Insert Stop… Indsæt stop… + Insert Stop Indsæt stop + Record Stop Optag stop + Allow Continue Tillad fortsæt + Continue Fortsæt + Continue Action Fortsæt handling + Delete Action Step Slet handlingstrin + Press Play Current Selection in the Actions panel to go on. Tryk på Afspil aktuel markering i panelet Handlinger for at fortsætte. + Stop: {message} Stop: {message} +@msg no action is waiting at a stop ingen handling venter ved et stop +@msg message must be text message skal være tekst +@msg allowContinue must be true or false allowContinue skal være true eller false +@msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop Playback Options Afspilningsindstillinger Accelerated Accelereret Step by Step Trin for trin @@ -6098,6 +6156,7 @@ breakText Glyphs Glyffer Gap 5 Mellemrum 5 @msg {_1}: they came in as the page's art {_1}: de blev importeret som sidens grafik +@msg warped text objects that came in as drawn art, not as editable type: {_1} vredne tekstobjekter, der blev importeret som grafik og ikke som redigerbar tekst: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objekter, der blev tegnet anderledes end filens egen side, blev importeret som sidens grafik: {_1} @msg You must choose a side pattern. Du skal vælge et sidemønster. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4a4142d88..7d0e48fb8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -59,6 +59,11 @@ 3 Color Job… 3-Farben-Auftrag… 30° Angles 30°-Winkel 3D and Materials 3D und Materialien + Plane Ebene + Extrude Extrudieren + Revolve Kreiseln + Inflate Aufblasen + Select objects, then pick an object type to give them a 3D effect. Wählen Sie Objekte aus und dann einen Objekttyp, um ihnen einen 3D-Effekt zu geben. 3D Ribbon 3D-Band 45° Angles 45°-Winkel 60° Angles 60°-Winkel @@ -259,6 +264,8 @@ font-technology Variable Variabel Show Cross Hairs Fadenkreuz einblenden Show Video Safe Areas Videosichere Bereiche einblenden Video Ruler Pixel Aspect Ratio: Pixel-Seitenverhältnis der Videolineale: + Fade Region Outside Artboard Bereich außerhalb der Zeichenfläche abblenden + Update While Dragging Beim Ziehen aktualisieren Hide Video Rulers Videolineale ausblenden Background Contents Hintergrundinhalt Background Contents: Hintergrundinhalt: @@ -1060,6 +1067,8 @@ font-technology Variable Variabel Export for Screens Settings Einstellungen für „Für Bildschirme exportieren“ Export for Screens… Für Bildschirme exportieren… Export Keyboard Shortcuts… Tastaturbefehle exportieren… + Export Keyboard Shortcuts as Text… Tastaturbefehle als Text exportieren… + Export Text… Text exportieren… Export Option Exportoption Export PDF PDF exportieren Export PDF Presets PDF-Vorgaben exportieren @@ -1358,6 +1367,8 @@ file-filter Templates Vorlagen Grays Grautöne Grays and Neutrals Grautöne und neutrale Farben Grayscale Graustufen + High Color Viele Farben + Low Color Wenige Farben Grayscale images Graustufenbilder Greek & Cyrillic Griechisch und Kyrillisch Green Grün @@ -1430,6 +1441,7 @@ file-filter Templates Vorlagen Step Backward Schritt zurück Step Forward Schritt vorwärts Clear History Protokoll löschen + New Document from State Neues Dokument aus Protokollschritt Set History Limit… Protokollgrenze festlegen… History States Protokollschritte # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1823,6 +1835,9 @@ file-filter Templates Vorlagen Live Paint Selection Tool Interaktiv-malen-Auswahlwerkzeug Live Shape Properties Eigenschaften interaktiver Formen Load Color Profile… Farbprofil laden… + Load Character Styles… Zeichenformate laden… + Load Paragraph Styles… Absatzformate laden… + Load All Styles… Alle Formate laden… Remove Color Profile… Farbprofil entfernen… Load Image into Clipboard Bild in die Zwischenablage laden Load Metafile into Clipboard Metadatei in die Zwischenablage laden @@ -2650,6 +2665,14 @@ digits Malayalam Malayalam Relink Neu verknüpfen Relink Linked Files to the Document Verknüpfte Dateien neu mit dem Dokument verknüpfen Relink to Folder… Mit Ordner neu verknüpfen… + Each Link Separately Jede Verknüpfung einzeln + Selected Instances Ausgewählte Instanzen + All Instances Alle Instanzen + Missing Links from This Folder Fehlende Verknüpfungen aus diesem Ordner +@msg scope {v} is not each|selected|all|missingInFolder scope {v} ist nicht each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each und paths gehören zusammen: paths listet Einträge mit ids und path anstelle von ids, path und folder +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-Eintrag {i} braucht path und ids (Objekt-IDs, die kein anderer Eintrag hat) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder nimmt path: Im Ordner dieser Datei wird nach den anderen fehlenden Verknüpfungen gesucht Relink… Neu verknüpfen… Find Linked Files Verknüpfte Dateien suchen Find Linked Files in Folder… Verknüpfte Dateien in Ordner suchen… @@ -2694,6 +2717,7 @@ digits Malayalam Malayalam 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 Replace Symbol Symbol ersetzen + Reset Transformation Transformation zurücksetzen Replace With Font Durch Schrift ersetzen Replace With Font From Ersetzende Schrift aus System System @@ -2710,6 +2734,8 @@ digits Malayalam Malayalam Reset OpenType Features OpenType-Funktionen zurücksetzen Reset Panel Bedienfeld zurücksetzen Reset Preferences Voreinstellungen zurücksetzen + Search Preferences Voreinstellungen durchsuchen + No settings found Keine Einstellungen gefunden Reset Profiles Profile zurücksetzen Rotate View Ansicht drehen Reset Rotate View Ansichtsdrehung zurücksetzen @@ -2765,6 +2791,7 @@ digits Malayalam Malayalam Rotate Tool Drehen-Werkzeug Rotate View Tool Ansichtdrehung-Werkzeug Trackpad Gesture to Rotate View Ansicht mit Trackpad-Geste drehen + Hold a Tool Shortcut to Use the Tool Temporarily Werkzeug-Tastaturbefehl gedrückt halten, um das Werkzeug vorübergehend zu verwenden Rotate… Drehen… Rotation Drehung Roughen… Aufrauen… @@ -3207,6 +3234,7 @@ effect Points Punkte Spaces Leerzeichen Spacing Guides Abstandshilfslinien Invoke Distance Guides Distanzhilfslinien aufrufen + Snap to Last Location An letzter Position ausrichten Spacing: Abstand: Spanish Spanisch Specified Distance Festgelegter Abstand @@ -3844,6 +3872,9 @@ theme Appearance Erscheinungsbild Put back in the toolbar Zurück in die Werkzeugleiste setzen Float Panel Bedienfeld ablösen Dock Panel Bedienfeld andocken + Dock to Top Oben andocken + Dock to Bottom Unten andocken + Dock Control Bar Steuerungsleiste andocken Drag to float the toolbar Ziehen, um die Werkzeugleiste abzulösen Drag to the window's left edge to dock Zum Andocken an den linken Fensterrand ziehen Float Tool Group Werkzeuggruppe ablösen @@ -4292,6 +4323,7 @@ theme Appearance Erscheinungsbild @msg nothing to save: the document has no visible art nichts zu speichern: Das Dokument enthält keine sichtbare Grafik @msg nothing to undo nichts rückgängig zu machen @msg no history to clear kein Protokoll zu löschen +@msg no history state {v} kein Protokollschritt {v} @msg nothing to warp nichts zu verkrümmen @msg nothing to wrap nichts zu umfließen @msg object {0} cannot have children Objekt {0} kann keine untergeordneten Objekte haben @@ -4540,6 +4572,7 @@ theme Appearance Erscheinungsbild @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} Bild konnte nicht dekodiert werden und wird als durchgekreuzter Rahmen angezeigt|{n} Bilder konnten nicht dekodiert werden und werden als durchgekreuzte Rahmen angezeigt @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} verknüpftes Bild wurde auf dem Datenträger geändert: „Verknüpfungen aktualisieren“ zeigt die neue Version|{n} verknüpfte Bilder wurden auf dem Datenträger geändert: „Verknüpfungen aktualisieren“ zeigt die neuen Versionen @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk {n} auf dem Datenträger geänderte verknüpfte Datei aktualisiert|{n} auf dem Datenträger geänderte verknüpfte Dateien aktualisiert +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings das Dokument legt einen Anschnitt fest, den diese PDF/X-Datei auslässt: „Anschnitteinstellungen des Dokuments verwenden“ aktivieren @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} mit {n} Hinweis exportiert: {first}|{what} mit {n} Hinweisen exportiert: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} Schriftdatei liegt jetzt in {folder}|{n} Schriftdateien liegen jetzt in {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} Schriftdatei liegt jetzt in {folder}; {failed} konnten nicht hinzugefügt werden: {reason}|{n} Schriftdateien liegen jetzt in {folder}; {failed} konnten nicht hinzugefügt werden: {reason} @@ -4673,6 +4706,7 @@ theme Appearance Erscheinungsbild ICF Box ICF-Rahmen @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` ist der Schrittverweis {r}, den nur `run` und `command_batch` auflösen @msg `ids` must be an array of object ids, not {v} `ids` muss ein Array von Objekt-IDs sein, nicht {v} +@msg `colors` must be an array of colors, not {v} `colors` muss ein Array von Farben sein, nicht {v} @msg `corners` must be an array of anchor indices, not {v} `corners` muss ein Array von Ankerpunkt-Indizes sein, nicht {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: Das Ergebnis von Schritt {i} hat keinen solchen Wert (schreiben Sie $$ für ein literales $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: Vor diesem Schritt gibt es keinen Schritt {i} (Schritte zählen ab 0; schreiben Sie $$ für ein literales $) @@ -5370,6 +5404,33 @@ axis Y Axis Y-Achse @msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein @msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt? + ID: {id} ID: {id} + Version: {version} Version: {version} + File SHA-256: {hash} Datei-SHA-256: {hash} + Read the objects it is given Die Objekte lesen, die es erhält + Change, add and delete objects in the document Objekte im Dokument ändern, hinzufügen und löschen + Keep data of its own with the document Eigene Daten mit dem Dokument speichern + Change how the objects its effect is applied to are drawn Ändern, wie die Objekte gezeichnet werden, auf die sein Effekt angewendet ist + Write the document as a file in its own format Das Dokument als Datei in einem eigenen Format schreiben + Show its progress, see Cancel and write log lines Den Fortschritt anzeigen, ein Abbrechen erkennen und Protokollzeilen schreiben +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `progress` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an +@msg the module imports `env.{name}` with another type than the host's: it is {signature} das Modul importiert `env.{name}` mit einem anderen Typ als dem des Hosts: Der Typ des Hosts ist {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `progress` verwenden darf +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur `env.vc_progress`, `env.vc_cancelled` und `env.vc_log` importieren (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ist {_1}; dieser Host unterstützt die Plug-in-API-Stufen 1 bis {API_LEVEL} +@msg `grant` must be a list of permission names `grant` muss eine Liste von Berechtigungsnamen sein +@msg `grant` names an unknown permission `{shown}` `grant` nennt eine unbekannte Berechtigung `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ein Plug-in der Art `{_1}` braucht die Berechtigung `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` höchstens {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` die Berechtigung `{shown}` gilt nicht für ein Plug-in der Art `{_1}` +@msg permission `{shown}` is listed twice die Berechtigung `{shown}` ist doppelt aufgeführt +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht +@msg unknown permission `{shown}` unbekannte Berechtigung `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert +@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 # Actions panel: action sets (#3712). New Set… Neuer Satz… New Set Neuer Satz @@ -5378,6 +5439,19 @@ axis Y Axis Y-Achse Action Options… Aktionsoptionen… Action Options Aktionsoptionen Playback Options… Wiedergabeoptionen… + Insert Stop… Stopp einfügen… + Insert Stop Stopp einfügen + Record Stop Stopp aufzeichnen + Allow Continue Fortsetzen erlauben + Continue Fortsetzen + Continue Action Aktion fortsetzen + Delete Action Step Aktionsschritt löschen + Press Play Current Selection in the Actions panel to go on. Klicken Sie im Bedienfeld „Aktionen“ auf „Aktuelle Auswahl abspielen“, um fortzufahren. + Stop: {message} Stopp: {message} +@msg no action is waiting at a stop keine Aktion wartet an einem Stopp +@msg message must be text message muss Text sein +@msg allowContinue must be true or false allowContinue muss true oder false sein +@msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen Playback Options Wiedergabeoptionen Accelerated Beschleunigt Step by Step Schrittweise @@ -5820,6 +5894,7 @@ breakText Glyphs Glyphen Gap 5 Lücke 5 @msg {_1}: they came in as the page's art {_1}: sie wurden als Seitengrafik importiert +@msg warped text objects that came in as drawn art, not as editable type: {_1} verkrümmte Textobjekte, die als Grafik und nicht als bearbeitbarer Text importiert wurden: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} Objekte, die anders als die eigene Seite der Datei gezeichnet wurden, wurden als Seitengrafik importiert: {_1} @msg You must choose a side pattern. Sie müssen ein Seitenmuster auswählen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6b2a022d5..be001e2fb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -66,6 +66,11 @@ 3 Color Job… Εργασία τριών χρωμάτων… 30° Angles Γωνίες 30° 3D and Materials 3D και υλικά + Plane Επίπεδο + Extrude Εξώθηση + Revolve Περιστροφή γύρω από άξονα + Inflate Φούσκωμα + Select objects, then pick an object type to give them a 3D effect. Επιλέξτε αντικείμενα και, στη συνέχεια, έναν τύπο αντικειμένου για να τους δώσετε εφέ 3D. 3D Ribbon Τρισδιάστατη κορδέλα 45° Angles Γωνίες 45° 60° Angles Γωνίες 60° @@ -262,6 +267,8 @@ font-technology Variable Μεταβλητές Show Cross Hairs Εμφάνιση σταυρονήματος Show Video Safe Areas Εμφάνιση ασφαλών περιοχών βίντεο Video Ruler Pixel Aspect Ratio: Αναλογία διαστάσεων pixel χαράκων βίντεο: + Fade Region Outside Artboard Σκίαση περιοχής εκτός πίνακα σχεδίασης + Update While Dragging Ενημέρωση κατά τη μεταφορά Hide Video Rulers Απόκρυψη χαράκων βίντεο Background Contents Περιεχόμενα φόντου Background Contents: Περιεχόμενο φόντου: @@ -1033,6 +1040,8 @@ font-technology Variable Μεταβλητές Export for Screens Settings Ρυθμίσεις εξαγωγής για οθόνες Export for Screens… Εξαγωγή για οθόνες… Export Keyboard Shortcuts… Εξαγωγή συντομεύσεων πληκτρολογίου… + Export Keyboard Shortcuts as Text… Εξαγωγή συντομεύσεων πληκτρολογίου ως κείμενο… + Export Text… Εξαγωγή κειμένου… Export Option Επιλογή εξαγωγής Export PDF Εξαγωγή PDF Export PDF Presets Εξαγωγή προεπιλογών PDF @@ -1297,6 +1306,8 @@ font-technology Variable Μεταβλητές Grays Γκρι τόνοι Grays and Neutrals Γκρι και ουδέτεροι τόνοι Grayscale Κλίμακα του γκρι + High Color Πολλά χρώματα + Low Color Λίγα χρώματα Grayscale images Εικόνες σε κλίμακα του γκρι Greek & Cyrillic Ελληνικά και κυριλλικά Green Πράσινο @@ -1369,6 +1380,7 @@ font-technology Variable Μεταβλητές Step Backward Ένα βήμα πίσω Step Forward Ένα βήμα εμπρός Clear History Εκκαθάριση ιστορικού + New Document from State Νέο έγγραφο από κατάσταση Set History Limit… Ορισμός ορίου ιστορικού… History States Καταστάσεις ιστορικού # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1869,6 +1881,8 @@ font-technology Variable Μεταβλητές Reselect Επαναφορά προηγούμενης επιλογής Reset Επαναφορά Reset Preferences Επαναφορά προτιμήσεων + Search Preferences Αναζήτηση στις προτιμήσεις + No settings found Δεν βρέθηκαν ρυθμίσεις Reset to Default Επαναφορά προεπιλογής Resolution: Ανάλυση: Restore Επαναφορά @@ -1881,6 +1895,7 @@ font-technology Variable Μεταβλητές Rotate Περιστροφή Rotate View Tool Εργαλείο περιστροφής προβολής (Rotate View Tool) Trackpad Gesture to Rotate View Περιστροφή προβολής με χειρονομία στην επιφάνεια αφής + Hold a Tool Shortcut to Use the Tool Temporarily Προσωρινή χρήση εργαλείου κρατώντας πατημένη τη συντόμευσή του Rotate… Περιστροφή… Rotation Περιστροφή Rows: Γραμμές: @@ -2134,6 +2149,9 @@ axis Vertical Κατακόρυφα axis Both Και τα δύο Float Panel Αιωρούμενο πάνελ Dock Panel Αγκύρωση πάνελ + Dock to Top Αγκύρωση επάνω + Dock to Bottom Αγκύρωση κάτω + Dock Control Bar Αγκύρωση γραμμής ελέγχου @msg no library `{id}` Δεν υπάρχει βιβλιοθήκη με αναγνωριστικό `{id}` @msg select some type first Επιλέξτε πρώτα κάποιο κείμενο @msg kerning must be a number, "auto", or "optical" Η διαγραμμάτωση πρέπει να είναι αριθμός, «auto» ή «optical» @@ -2634,6 +2652,9 @@ theme Appearance Εμφάνιση Live Paint Selection Tool Επιλογή δυναμικής βαφής (Live Paint Selection Tool) Live Shape Properties Ιδιότητες δυναμικού σχήματος Load Color Profile… Φόρτωση προφίλ χρώματος… + Load Character Styles… Φόρτωση στυλ χαρακτήρων… + Load Paragraph Styles… Φόρτωση στυλ παραγράφων… + Load All Styles… Φόρτωση όλων των στυλ… Remove Color Profile… Αφαίρεση προφίλ χρώματος… Load Image into Clipboard Φόρτωση εικόνας στο πρόχειρο Load Metafile into Clipboard Φόρτωση μετααρχείου στο πρόχειρο @@ -3254,6 +3275,14 @@ digits Malayalam Μαλαγιάλαμ Relink Επανασύνδεση Relink Linked Files to the Document Επανασύνδεση συνδεδεμένων αρχείων με το έγγραφο Relink to Folder… Επανασύνδεση σε φάκελο… + Each Link Separately Κάθε σύνδεση χωριστά + Selected Instances Επιλεγμένες παρουσίες + All Instances Όλες οι παρουσίες + Missing Links from This Folder Συνδέσεις που λείπουν από αυτόν τον φάκελο +@msg scope {v} is not each|selected|all|missingInFolder το scope {v} δεν είναι each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder τα scope each και paths πάνε μαζί: το paths απαριθμεί καταχωρίσεις με ids και path, αντί για ids, path και folder +@msg paths entry {i} needs path and ids (object ids no other entry has) η καταχώριση {i} του paths χρειάζεται path και ids (αναγνωριστικά αντικειμένων που δεν έχει άλλη καταχώριση) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links το scope missingInFolder δέχεται path: στον φάκελο αυτού του αρχείου αναζητούνται οι άλλες συνδέσεις που λείπουν Relink… Επανασύνδεση… Find Linked Files Εύρεση συνδεδεμένων αρχείων Find Linked Files in Folder… Εύρεση συνδεδεμένων αρχείων σε φάκελο… @@ -3293,6 +3322,7 @@ digits Malayalam Μαλαγιάλαμ Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Αντικαταστήστε το με άλλο αρχείο ή αγνοήστε το: οι εικόνες του θα συνεχίσουν να εμφανίζουν την προεπισκόπηση που αποθηκεύτηκε στο έγγραφο. Replace Spine Αντικατάσταση κεντρικής καμπύλης Replace Symbol Αντικατάσταση συμβόλου + Reset Transformation Επαναφορά μετασχηματισμού Replace With Font Αντικατάσταση με γραμματοσειρά Replace With Font From Αντικατάσταση με γραμματοσειρά από System Σύστημα @@ -3630,6 +3660,7 @@ digits Malayalam Μαλαγιάλαμ Spaces Διαστήματα Spacing Guides Οδηγοί αποστάσεων Invoke Distance Guides Εμφάνιση οδηγών μέτρησης απόστασης + Snap to Last Location Προσκόλληση στην τελευταία θέση Spanish Ισπανικά Specified Distance Καθορισμένη απόσταση Specified Steps Καθορισμένα βήματα @@ -4331,6 +4362,7 @@ digits Malayalam Μαλαγιάλαμ @msg nothing to save: the document has no visible art δεν υπάρχει τίποτα για αποθήκευση: το έγγραφο δεν έχει ορατό σχέδιο @msg nothing to undo δεν υπάρχει τίποτα για αναίρεση @msg no history to clear δεν υπάρχει ιστορικό για εκκαθάριση +@msg no history state {v} δεν υπάρχει κατάσταση ιστορικού {v} @msg nothing to warp δεν υπάρχει τίποτα για στρέβλωση @msg nothing to wrap δεν υπάρχει τίποτα για αναδίπλωση @msg object {0} cannot have children το αντικείμενο {0} δεν μπορεί να έχει θυγατρικά στοιχεία @@ -4573,6 +4605,7 @@ digits Malayalam Μαλαγιάλαμ @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} εικόνα δεν ήταν δυνατό να αποκωδικοποιηθεί: εμφανίζεται ως πλαίσιο με σταυρό|{n} εικόνες δεν ήταν δυνατό να αποκωδικοποιηθούν: εμφανίζονται ως πλαίσια με σταυρό @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} συνδεδεμένη εικόνα άλλαξε στον δίσκο: η Ενημέρωση συνδέσμων εμφανίζει τη νέα έκδοση|{n} συνδεδεμένες εικόνες άλλαξαν στον δίσκο: η Ενημέρωση συνδέσμων εμφανίζει τις νέες εκδόσεις @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Ενημερώθηκε {n} συνδεδεμένο αρχείο που άλλαξε στον δίσκο|Ενημερώθηκαν {n} συνδεδεμένα αρχεία που άλλαξαν στον δίσκο +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings το έγγραφο ορίζει ξάκρισμα που αυτό το αρχείο PDF/X παραλείπει: ενεργοποιήστε τη «Χρήση ρυθμίσεων ξακρίσματος εγγράφου» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Εξήχθη το {what} με {n} σημείωση: {first}|Εξήχθη το {what} με {n} σημειώσεις: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} αρχείο γραμματοσειράς βρίσκεται πλέον στον φάκελο {folder}|{n} αρχεία γραμματοσειρών βρίσκονται πλέον στον φάκελο {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} αρχείο γραμματοσειράς βρίσκεται πλέον στον φάκελο {folder}· δεν ήταν δυνατή η προσθήκη {failed}: {reason}|{n} αρχεία γραμματοσειρών βρίσκονται πλέον στον φάκελο {folder}· δεν ήταν δυνατή η προσθήκη {failed}: {reason} @@ -4697,6 +4730,7 @@ digits Malayalam Μαλαγιάλαμ ICF Box Πλαίσιο ICF @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve το `id` είναι η αναφορά βήματος {r}, την οποία αναλύουν μόνο τα `run` και `command_batch` @msg `ids` must be an array of object ids, not {v} το `ids` πρέπει να είναι πίνακας αναγνωριστικών αντικειμένων, όχι {v} +@msg `colors` must be an array of colors, not {v} το `colors` πρέπει να είναι πίνακας χρωμάτων, όχι {v} @msg `corners` must be an array of anchor indices, not {v} το `corners` πρέπει να είναι πίνακας δεικτών αγκύρωσης, όχι {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: το αποτέλεσμα του βήματος {i} δεν έχει τέτοια τιμή (γράψτε $$ για κυριολεκτικό $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: δεν υπάρχει βήμα {i} πριν από αυτό (η αρίθμηση αρχίζει από 0· γράψτε $$ για κυριολεκτικό $) @@ -5739,6 +5773,33 @@ axis Y Axis Άξονας Y @msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy` @msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; + ID: {id} Αναγνωριστικό: {id} + Version: {version} Έκδοση: {version} + File SHA-256: {hash} SHA-256 αρχείου: {hash} + Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται + Change, add and delete objects in the document Αλλαγή, προσθήκη και διαγραφή αντικειμένων στο έγγραφο + Keep data of its own with the document Αποθήκευση δικών του δεδομένων μαζί με το έγγραφο + Change how the objects its effect is applied to are drawn Αλλαγή του τρόπου σχεδίασης των αντικειμένων στα οποία εφαρμόζεται το εφέ του + Write the document as a file in its own format Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή + Show its progress, see Cancel and write log lines Εμφάνιση της προόδου, αναγνώριση της ακύρωσης και εγγραφή γραμμών καταγραφής +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `progress` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} η λειτουργική μονάδα εισάγει το `env.{name}` με άλλον τύπο από αυτόν του κεντρικού υπολογιστή: ο τύπος του είναι {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τα `env.vc_progress`, `env.vc_cancelled` και `env.vc_log` (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL} +@msg `grant` must be a list of permission names το `grant` πρέπει να είναι λίστα ονομάτων δικαιωμάτων +@msg `grant` names an unknown permission `{shown}` το `grant` αναφέρει άγνωστο δικαίωμα `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ένα πρόσθετο τύπου `{_1}` χρειάζεται το δικαίωμα `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` το πολύ {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` το δικαίωμα `{shown}` δεν ισχύει για πρόσθετο τύπου `{_1}` +@msg permission `{shown}` is listed twice το δικαίωμα `{shown}` αναφέρεται δύο φορές +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί +@msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα # Actions panel: action sets (#3712). New Set… Νέο σύνολο… New Set Νέο σύνολο @@ -5747,6 +5808,19 @@ axis Y Axis Άξονας Y Action Options… Επιλογές ενέργειας… Action Options Επιλογές ενέργειας Playback Options… Επιλογές αναπαραγωγής… + Insert Stop… Εισαγωγή διακοπής… + Insert Stop Εισαγωγή διακοπής + Record Stop Καταγραφή διακοπής + Allow Continue Να επιτρέπεται η συνέχεια + Continue Συνέχεια + Continue Action Συνέχεια ενέργειας + Delete Action Step Διαγραφή βήματος ενέργειας + Press Play Current Selection in the Actions panel to go on. Για να συνεχίσετε, πατήστε «Αναπαραγωγή τρέχουσας επιλογής» στο πάνελ Ενέργειες. + Stop: {message} Διακοπή: {message} +@msg no action is waiting at a stop καμία ενέργεια δεν περιμένει σε διακοπή +@msg message must be text Το message πρέπει να είναι κείμενο +@msg allowContinue must be true or false Το allowContinue πρέπει να είναι true ή false +@msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή Playback Options Επιλογές αναπαραγωγής Accelerated Επιταχυμένα Step by Step Βήμα προς βήμα @@ -6186,6 +6260,7 @@ breakText Glyphs Γλύφοι Gap 5 Διάκενο 5 @msg {_1}: they came in as the page's art {_1}: εισήχθησαν ως γραφικά της σελίδας +@msg warped text objects that came in as drawn art, not as editable type: {_1} παραμορφωμένα αντικείμενα κειμένου που εισήχθησαν ως γραφικά, όχι ως επεξεργάσιμο κείμενο: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} τα αντικείμενα που σχεδιάστηκαν διαφορετικά από την ίδια τη σελίδα του αρχείου εισήχθησαν ως γραφικά της σελίδας: {_1} @msg You must choose a side pattern. Πρέπει να επιλέξετε πλευρικό μοτίβο. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6ccd50b46..930ecf090 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -84,6 +84,11 @@ 3 Color Job… Trabajo a 3 colores… 30° Angles Ángulos de 30° 3D and Materials 3D y materiales + Plane Plano + Extrude Extrusión + Revolve Revolución + Inflate Inflar + Select objects, then pick an object type to give them a 3D effect. Seleccione objetos y luego elija un tipo de objeto para darles un efecto 3D. 3D Ribbon Cinta 3D 45° Angles Ángulos de 45° 60° Angles Ángulos de 60° @@ -284,6 +289,8 @@ font-technology Variable Variables Show Cross Hairs Mostrar líneas en cruz Show Video Safe Areas Mostrar áreas seguras de video Video Ruler Pixel Aspect Ratio: Proporción de píxel de las reglas de video: + Fade Region Outside Artboard Atenuar región fuera de la mesa de trabajo + Update While Dragging Actualizar al arrastrar Hide Video Rulers Ocultar reglas de video Background Contents Contenido de fondo Background Contents: Contenido de fondo: @@ -1089,6 +1096,8 @@ font-technology Variable Variables Export for Screens Settings Ajustes de Exportar para pantallas Export for Screens… Exportar para pantallas… Export Keyboard Shortcuts… Exportar métodos abreviados de teclado… + Export Keyboard Shortcuts as Text… Exportar métodos abreviados de teclado como texto… + Export Text… Exportar texto… Export Option Opción de exportación Export PDF Exportar PDF Export PDF Presets Exportar preajustes de PDF @@ -1376,6 +1385,8 @@ font-technology Variable Variables Grays Grises Grays and Neutrals Grises y neutros Grayscale Escala de grises + High Color Muchos colores + Low Color Pocos colores Grayscale images Imágenes en escala de grises Greek & Cyrillic Griego y cirílico Green Verde @@ -1449,6 +1460,7 @@ font-technology Variable Variables Step Backward Paso atrás Step Forward Paso adelante Clear History Borrar historial + New Document from State Nuevo documento desde estado Set History Limit… Definir límite de historial… History States Estados de historial # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1826,6 +1838,9 @@ font-technology Variable Variables Live Paint Selection Tool Herramienta Selección de pintura interactiva Live Shape Properties Propiedades de forma dinámica Load Color Profile… Cargar perfil de color… + Load Character Styles… Cargar estilos de carácter… + Load Paragraph Styles… Cargar estilos de párrafo… + Load All Styles… Cargar todos los estilos… Remove Color Profile… Quitar perfil de color… Load Image into Clipboard Cargar imagen en el portapapeles Load Metafile into Clipboard Cargar metarchivo en el portapapeles @@ -2693,6 +2708,14 @@ digits Malayalam Malayálam Relink Volver a vincular Relink Linked Files to the Document Volver a vincular los archivos vinculados al documento Relink to Folder… Volver a vincular a carpeta… + Each Link Separately Cada vínculo por separado + Selected Instances Instancias seleccionadas + All Instances Todas las instancias + Missing Links from This Folder Vínculos que faltan de esta carpeta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} no es each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each y paths van juntos: paths enumera entradas con ids y path, en lugar de ids, path y folder +@msg paths entry {i} needs path and ids (object ids no other entry has) la entrada {i} de paths necesita path e ids (id. de objeto que ninguna otra entrada tenga) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder toma path: en la carpeta de ese archivo se buscan los demás vínculos que faltan Relink… Volver a vincular… Find Linked Files Buscar archivos vinculados Find Linked Files in Folder… Buscar archivos vinculados en una carpeta… @@ -2737,6 +2760,7 @@ digits Malayalam Malayálam 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 Replace Symbol Reemplazar símbolo + Reset Transformation Restablecer transformación Replace With Font Reemplazar con fuente Replace With Font From Reemplazar con fuente de System Sistema @@ -2753,6 +2777,8 @@ digits Malayalam Malayálam Reset OpenType Features Restablecer funciones OpenType Reset Panel Restablecer panel Reset Preferences Restablecer preferencias + Search Preferences Buscar en preferencias + No settings found No se encontraron ajustes Reset Profiles Restablecer perfiles Reset Rotate View Restablecer rotación de vista Rotate View to Selection Rotar vista según la selección @@ -2807,6 +2833,7 @@ digits Malayalam Malayálam Rotate Tool Herramienta Rotar Rotate View Tool Herramienta Rotar vista Trackpad Gesture to Rotate View Rotar vista con un gesto del trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Mantener pulsado el atajo de una herramienta para usarla temporalmente Rotate… Rotar… Rotation Rotación Roughen… Hacer áspero… @@ -3246,6 +3273,7 @@ digits Malayalam Malayálam Spaces Espacios Spacing Guides Guías de espaciado Invoke Distance Guides Invocar guías de distancia + Snap to Last Location Ajustar a última ubicación Spacing: Espaciado: Spanish Español Specified Distance Distancia especificada @@ -3929,6 +3957,9 @@ axis Both Ambos Put back in the toolbar Devolver a la barra de herramientas Float Panel Hacer flotar el panel Dock Panel Anclar panel + Dock to Top Anclar arriba + Dock to Bottom Anclar abajo + Dock Control Bar Anclar barra de control Drag to float the toolbar Arrastrar para hacer flotar la barra de herramientas Drag to the window's left edge to dock Arrastrar al borde izquierdo de la ventana para anclar Float Tool Group Hacer flotar el grupo de herramientas @@ -4376,6 +4407,7 @@ axis Both Ambos @msg nothing to save: the document has no visible art no hay nada que guardar: el documento no tiene ilustración visible @msg nothing to undo no hay nada que deshacer @msg no history to clear no hay historial que borrar +@msg no history state {v} no hay ningún estado del historial {v} @msg nothing to warp no hay nada que deformar @msg nothing to wrap no hay nada que envolver @msg object {0} cannot have children el objeto {0} no puede tener hijos @@ -4624,6 +4656,7 @@ axis Both Ambos @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes no se pudo descodificar {n} imagen: se muestra como un recuadro tachado|no se pudieron descodificar {n} imágenes: se muestran como recuadros tachados @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} imagen vinculada cambió en el disco: Actualizar vínculos muestra la versión nueva|{n} imágenes vinculadas cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Se actualizó {n} archivo vinculado que cambió en el disco|Se actualizaron {n} archivos vinculados que cambiaron en el disco +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings el documento define un sangrado que este archivo PDF/X omite: activa «Usar los ajustes de sangrado del documento» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Se exportó {what} con {n} nota: {first}|Se exportó {what} con {n} notas: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} archivo de fuentes ahora está en {folder}|{n} archivos de fuentes ahora están en {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} archivo de fuentes ahora está en {folder}; no se pudieron agregar {failed}: {reason}|{n} archivos de fuentes ahora están en {folder}; no se pudieron agregar {failed}: {reason} @@ -4736,6 +4769,7 @@ axis Both Ambos Perspective Distort Distorsión de perspectiva @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` es la referencia a un paso {r}, que solo `run` y `command_batch` resuelven @msg `ids` must be an array of object ids, not {v} `ids` debe ser una lista de identificadores de objeto, no {v} +@msg `colors` must be an array of colors, not {v} `colors` debe ser una lista de colores, no {v} @msg `corners` must be an array of anchor indices, not {v} `corners` debe ser una lista de índices de puntos de ancla, no {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal) @@ -5729,6 +5763,33 @@ axis Y Axis Eje Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? + ID: {id} ID: {id} + Version: {version} Versión: {version} + File SHA-256: {hash} SHA-256 del archivo: {hash} + Read the objects it is given Leer los objetos que recibe + Change, add and delete objects in the document Cambiar, añadir y eliminar objetos del documento + Keep data of its own with the document Guardar datos propios con el documento + Change how the objects its effect is applied to are drawn Cambiar cómo se dibujan los objetos a los que se aplica su efecto + Write the document as a file in its own format Escribir el documento como un archivo en su propio formato + Show its progress, see Cancel and write log lines Mostrar su progreso, detectar la cancelación y escribir líneas de registro +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `progress` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} el módulo importa `env.{name}` con un tipo distinto del del host: el del host es {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar `env.vc_progress`, `env.vc_cancelled` y `env.vc_log` (nivel de API de plugins 2), y ningún WASI, archivo ni red +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` es {_1}; este host admite los niveles de API de plugins de 1 a {API_LEVEL} +@msg `grant` must be a list of permission names `grant` debe ser una lista de nombres de permisos +@msg `grant` names an unknown permission `{shown}` `grant` nombra un permiso desconocido `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plugin de tipo `{_1}` necesita el permiso `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` como máximo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` el permiso `{shown}` no se aplica a un plugin de tipo `{_1}` +@msg permission `{shown}` is listed twice el permiso `{shown}` aparece dos veces +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede +@msg unknown permission `{shown}` permiso desconocido `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada +@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 # Actions panel: action sets (#3712). New Set… Nuevo conjunto… New Set Nuevo conjunto @@ -5737,6 +5798,19 @@ axis Y Axis Eje Y Action Options… Opciones de acción… Action Options Opciones de acción Playback Options… Opciones de reproducción… + Insert Stop… Insertar parada… + Insert Stop Insertar parada + Record Stop Grabar parada + Allow Continue Permitir continuar + Continue Continuar + Continue Action Continuar acción + Delete Action Step Eliminar paso de la acción + Press Play Current Selection in the Actions panel to go on. Para continuar, pulse Reproducir la selección actual en el panel Acciones. + Stop: {message} Parada: {message} +@msg no action is waiting at a stop ninguna acción está esperando en una parada +@msg message must be text message debe ser texto +@msg allowContinue must be true or false allowContinue debe ser true o false +@msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada Playback Options Opciones de reproducción Accelerated Acelerada Step by Step Paso a paso @@ -6176,6 +6250,7 @@ breakText Glyphs Glifos Gap 5 Hueco 5 @msg {_1}: they came in as the page's art {_1}: se importaron como el arte de la página +@msg warped text objects that came in as drawn art, not as editable type: {_1} objetos de texto deformados que se importaron como arte, no como texto editable: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} los objetos que se dibujaban de forma distinta a la página del propio archivo se importaron como el arte de la página: {_1} @msg You must choose a side pattern. Debe elegir un motivo lateral. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3cf8607c0..99594975d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -453,6 +453,11 @@ Document Raster Effects Settings… Asiakirjan rasteritehosteiden asetukset… Vector Effects Vektoritehosteet 3D and Materials 3D ja materiaalit + Plane Taso + Extrude Pursota + Revolve Pyöräytä + Inflate Täytä ilmalla + Select objects, then pick an object type to give them a 3D effect. Valitse objektit ja valitse sitten objektityyppi, niin niille tulee 3D-tehoste. Extrude & Bevel… Pursota ja viistä… Revolve… Pyöräytä… Inflate… Täytä ilmalla… @@ -807,6 +812,9 @@ Put back in the toolbar Palauta työkaluriviin Float Panel Kelluta paneeli Dock Panel Telakoi paneeli + Dock to Top Telakoi ylös + Dock to Bottom Telakoi alas + Dock Control Bar Telakoi ohjauspalkki Float Tool Group Kelluta työkaluryhmä Services Palvelut Hide VectorCraft Piilota VectorCraft @@ -936,6 +944,8 @@ lighting Light Valo Expand Layers Panel Rows Laajenna tasopaneelin rivit Export CSS… Vie CSS… Export Keyboard Shortcuts… Vie pikanäppäimet… + Export Keyboard Shortcuts as Text… Vie pikanäppäimet tekstinä… + Export Text… Vie teksti… File Info Dialog Tiedoston tiedot -valintaikkuna Go to Artboard Siirry piirtoalustaan First Artboard Ensimmäinen piirtoalusta @@ -1285,6 +1295,7 @@ lighting Light Valo Rotate Tool Kiertotyökalu Rotate View Tool Näkymän kiertotyökalu Trackpad Gesture to Rotate View Kierrä näkymää ohjauslevyn eleellä + Hold a Tool Shortcut to Use the Tool Temporarily Käytä työkalua tilapäisesti pitämällä sen pikanäppäintä painettuna Rotate View Kierrä näkymää Rounded Rectangle Tool Pyöristetyn suorakulmion työkalu Scale Tool Skaalaustyökalu @@ -1607,6 +1618,7 @@ digits Malayalam Malajalam Step Backward Askel taaksepäin Step Forward Askel eteenpäin Clear History Tyhjennä historia + New Document from State Uusi asiakirja tilasta Set History Limit… Aseta historian raja… Open a document to see its history. Avaa asiakirja, niin näet sen historian. # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2042,6 +2054,14 @@ digits Malayalam Malajalam Location Sijainti No images Ei kuvia Relink to Folder… Linkitä uudelleen kansioon… + Each Link Separately Jokainen linkki erikseen + Selected Instances Valitut esiintymät + All Instances Kaikki esiintymät + Missing Links from This Folder Puuttuvat linkit tästä kansiosta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} ei ole each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each ja paths kuuluvat yhteen: paths luettelee merkinnät, joissa on ids ja path, ids:n, path:n ja folder:n sijaan +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-merkintä {i} tarvitsee path- ja ids-arvot (objektitunnukset, joita millään muulla merkinnällä ei ole) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder ottaa path-arvon: muita puuttuvia linkkejä haetaan tämän tiedoston kansiosta Show Missing Näytä puuttuvat Sort by Status Lajittele tilan mukaan Clear Overrides Tyhjennä ohitukset @@ -2530,6 +2550,8 @@ digits Malayalam Malajalam Show Cross Hairs Näytä hiusristikko Show Video Safe Areas Näytä videon turva-alueet Video Ruler Pixel Aspect Ratio: Videoviivainten kuvapisteen kuvasuhde: + Fade Region Outside Artboard Himmennä piirtoalustan ulkopuolinen alue + Update While Dragging Päivitä vedettäessä Hide Video Rulers Piilota videoviivaimet Client-side (.html) Asiakaspuoli (.html) Color Reduction: Värien vähennys: @@ -3138,6 +3160,7 @@ theme Appearance Ulkoasu Redefine Symbol Määritä symboli uudelleen Remove Brush Stroke Poista sivellinviiva Replace Symbol Korvaa symboli + Reset Transformation Palauta muunnos Select All Instances Valitse kaikki ilmentymät Symbol Sprayer Symbolisuihkutin Symbolism Tool Symbolityökalu @@ -3165,6 +3188,9 @@ theme Appearance Ulkoasu Convert Document Color Mode Muunna asiakirjan väritila Gamut Warning Väriavaruusvaroitus Load Color Profile… Lataa väriprofiili… + Load Character Styles… Lataa merkkityylit… + Load Paragraph Styles… Lataa kappaletyylit… + Load All Styles… Lataa kaikki tyylit… Remove Color Profile… Poista väriprofiili… Plates Erottelut Spot Color Spottiväri @@ -3595,6 +3621,8 @@ theme Appearance Ulkoasu Real-time Drawing and Editing Reaaliaikainen piirto ja muokkaus Render Threads (-1 = Automatic) Renderöintisäikeet (-1 = automaattinen) Reset Preferences Palauta asetukset + Search Preferences Hae asetuksista + No settings found Asetuksia ei löytynyt Scale Corners Skaalaa kulmat Scale Cursor Proportional to UI Skaalaa kohdistin suhteessa käyttöliittymään Scale Strokes & Effects Skaalaa viivat ja tehosteet @@ -3615,6 +3643,7 @@ theme Appearance Ulkoasu Snapping Tolerance Tartuntatoleranssi Spacing Guides Välistysapuviivat Invoke Distance Guides Käytä etäisyysapuviivoja + Snap to Last Location Kohdista viimeiseen sijaintiin Spanish Espanja Subdivisions Jaot Templates Folder Mallipohjakansio @@ -3782,6 +3811,8 @@ axis Both Molemmat Center Keskitä Transform in Perspective Muunna perspektiivissä Grayscale Harmaasävy + High Color Paljon värejä + Low Color Vähän värejä Embed Upota Star Tähti Power Saving (integrated) Virransäästö (integroitu) @@ -4590,6 +4621,7 @@ imageTrace Outline Ääriviiva @msg nothing to save: the document has no visible art ei tallennettavaa: asiakirjassa ei ole näkyvää kuvitusta @msg nothing to undo ei kumottavaa @msg no history to clear ei tyhjennettävää historiaa +@msg no history state {v} ei historiatilaa {v} @msg nothing to warp ei taivutettavaa @msg nothing to wrap ei rivitettävää @msg object {0} cannot have children objektilla {0} ei voi olla alikohteita @@ -4832,6 +4864,7 @@ imageTrace Outline Ääriviiva @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} kuvaa ei voitu purkaa: se näkyy ristillä merkittynä ruutuna|{n} kuvaa ei voitu purkaa: ne näkyvät ristillä merkittyinä ruutuina @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} linkitetty kuva muuttui levyllä: Päivitä linkit näyttää uuden version|{n} linkitettyä kuvaa muuttui levyllä: Päivitä linkit näyttää uudet versiot @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Päivitettiin {n} levyllä muuttunut linkitetty tiedosto|Päivitettiin {n} levyllä muuttunutta linkitettyä tiedostoa +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings asiakirjassa on leikkuuvara, jonka tämä PDF/X-tiedosto jättää pois: ota käyttöön ”Käytä asiakirjan leikkuuvara-asetuksia” @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Vietiin {what}, {n} huomautus: {first}|Vietiin {what}, {n} huomautusta: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} fonttitiedosto on nyt kansiossa {folder}|{n} fonttitiedostoa on nyt kansiossa {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} fonttitiedosto on nyt kansiossa {folder}; {failed} ei voitu lisätä: {reason}|{n} fonttitiedostoa on nyt kansiossa {folder}; {failed} ei voitu lisätä: {reason} @@ -4918,6 +4951,7 @@ imageTrace Outline Ääriviiva @msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat @msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v} +@msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v} @msg `corners` must be an array of anchor indices, not {v} `corners` on oltava ankkuri-indeksien taulukko, ei {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: vaiheen {i} tuloksessa ei ole tällaista arvoa (kirjoita $$, jos tarkoitat merkkiä $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: ennen tätä ei ole vaihetta {i} (vaiheet numeroidaan 0:sta; kirjoita $$, jos tarkoitat merkkiä $) @@ -5818,6 +5852,33 @@ axis Y Axis Y-akseli @msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy` @msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? + ID: {id} Tunnus: {id} + Version: {version} Versio: {version} + File SHA-256: {hash} Tiedoston SHA-256: {hash} + Read the objects it is given Lukea sille annetut objektit + Change, add and delete objects in the document Muuttaa, lisätä ja poistaa asiakirjan objekteja + Keep data of its own with the document Tallentaa omia tietojaan asiakirjaan + Change how the objects its effect is applied to are drawn Muuttaa sitä, miten objektit, joihin sen tehostetta on käytetty, piirretään + Write the document as a file in its own format Kirjoittaa asiakirjan tiedostoksi omassa muodossaan + Show its progress, see Cancel and write log lines Näyttää edistymisensä, havaita peruutuksen ja kirjoittaa lokirivejä +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `progress` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} moduuli tuo kohteen `env.{name}` eri tyyppisenä kuin isäntä: isännän tyyppi on {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain kohteet `env.vc_progress`, `env.vc_cancelled` ja `env.vc_log` (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` on {_1}; tämä isäntä tukee laajennus-API:n tasoja 1 ja {API_LEVEL} välillä +@msg `grant` must be a list of permission names `grant` on oltava luettelo käyttöoikeuksien nimistä +@msg `grant` names an unknown permission `{shown}` `grant` nimeää tuntemattoman käyttöoikeuden `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` lajin `{_1}` laajennus tarvitsee käyttöoikeuden `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` enintään {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` käyttöoikeus `{shown}` ei koske lajin `{_1}` laajennusta +@msg permission `{shown}` is listed twice käyttöoikeus `{shown}` on lueteltu kahdesti +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä +@msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu +@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 # Actions panel: action sets (#3712). New Set… Uusi sarja… New Set Uusi sarja @@ -5826,6 +5887,19 @@ axis Y Axis Y-akseli Action Options… Toiminnon asetukset… Action Options Toiminnon asetukset Playback Options… Toistoasetukset… + Insert Stop… Lisää pysäytys… + Insert Stop Lisää pysäytys + Record Stop Nauhoita pysäytys + Allow Continue Salli jatkaminen + Continue Jatka + Continue Action Jatka toimintoa + Delete Action Step Poista toiminnon vaihe + Press Play Current Selection in the Actions panel to go on. Jatka valitsemalla Toiminnot-paneelista Toista nykyinen valinta. + Stop: {message} Pysäytys: {message} +@msg no action is waiting at a stop mikään toiminto ei odota pysäytyksessä +@msg message must be text message on oltava tekstiä +@msg allowContinue must be true or false allowContinue on oltava true tai false +@msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys Playback Options Toistoasetukset Accelerated Nopeutettu Step by Step Vaihe kerrallaan @@ -6264,6 +6338,7 @@ breakText Glyphs Merkit Gap 5 Väli 5 @msg {_1}: they came in as the page's art {_1}: ne tuotiin sivun piirroksena +@msg warped text objects that came in as drawn art, not as editable type: {_1} vääristetyt tekstiobjektit, jotka tuotiin piirroksena eikä muokattavana tekstinä: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objektit, jotka piirtyivät eri tavoin kuin tiedoston oma sivu, tuotiin sivun piirroksena: {_1} @msg You must choose a side pattern. Valitse sivukuvio. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 67a7cb29c..1aebc348d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -85,6 +85,11 @@ 3 Color Job… Travail en 3 couleurs… 30° Angles Angles de 30° 3D and Materials 3D et matières + Plane Plan + Extrude Extrusion + Revolve Révolution + Inflate Gonfler + Select objects, then pick an object type to give them a 3D effect. Sélectionnez des objets, puis choisissez un type d’objet pour leur donner un effet 3D. 3D Ribbon Ruban 3D 45° Angles Angles de 45° 60° Angles Angles de 60° @@ -285,6 +290,8 @@ font-technology Variable Variables Show Cross Hairs Afficher le réticule Show Video Safe Areas Afficher les zones de sécurité vidéo Video Ruler Pixel Aspect Ratio: Format des pixels des règles vidéo : + Fade Region Outside Artboard Assombrir la zone hors du plan de travail + Update While Dragging Mettre à jour pendant le déplacement Hide Video Rulers Masquer les règles vidéo Background Contents Contenu de l’arrière-plan Background Contents: Contenu de l’arrière-plan : @@ -1084,6 +1091,8 @@ font-technology Variable Variables Export for Screens Settings Paramètres d’Exporter pour les écrans Export for Screens… Exporter pour les écrans… Export Keyboard Shortcuts… Exporter les raccourcis clavier… + Export Keyboard Shortcuts as Text… Exporter les raccourcis clavier en texte… + Export Text… Exporter le texte… Export Option Option d’exportation Export PDF Exporter en PDF Export PDF Presets Exporter les paramètres prédéfinis PDF @@ -1370,6 +1379,8 @@ font-technology Variable Variables Grays Gris Grays and Neutrals Gris et neutres Grayscale Niveaux de gris + High Color Beaucoup de couleurs + Low Color Peu de couleurs Grayscale images Images en niveaux de gris Greek & Cyrillic Grec et cyrillique Green Vert @@ -1443,6 +1454,7 @@ font-technology Variable Variables Step Backward Pas en arrière Step Forward Pas en avant Clear History Effacer l’historique + New Document from State Nouveau document à partir de l’état Set History Limit… Définir la limite de l’historique… History States États de l’historique # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1820,6 +1832,9 @@ font-technology Variable Variables Live Paint Selection Tool Outil Sélection de peinture dynamique Live Shape Properties Propriétés de la forme dynamique Load Color Profile… Charger un profil colorimétrique… + Load Character Styles… Charger les styles de caractère… + Load Paragraph Styles… Charger les styles de paragraphe… + Load All Styles… Charger tous les styles… Remove Color Profile… Supprimer un profil colorimétrique… Load Image into Clipboard Charger une image dans le presse-papiers Load Metafile into Clipboard Charger un métafichier dans le presse-papiers @@ -2685,6 +2700,14 @@ digits Malayalam Malayalam Relink Lier à nouveau Relink Linked Files to the Document Lier à nouveau les fichiers liés au document Relink to Folder… Lier à nouveau à un dossier… + Each Link Separately Chaque lien séparément + Selected Instances Occurrences sélectionnées + All Instances Toutes les occurrences + Missing Links from This Folder Liens manquants de ce dossier +@msg scope {v} is not each|selected|all|missingInFolder scope {v} n’est pas each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each et paths vont ensemble : paths liste des entrées avec ids et path, à la place de ids, path et folder +@msg paths entry {i} needs path and ids (object ids no other entry has) l’entrée {i} de paths nécessite path et ids (des identifiants d’objet qu’aucune autre entrée n’a) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder prend path : les autres liens manquants sont cherchés dans le dossier de ce fichier Relink… Lier à nouveau… Find Linked Files Rechercher des fichiers liés Find Linked Files in Folder… Rechercher des fichiers liés dans un dossier… @@ -2729,6 +2752,7 @@ digits Malayalam Malayalam 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 Replace Symbol Remplacer le symbole + Reset Transformation Réinitialiser la transformation Replace With Font Remplacer par la police Replace With Font From Remplacer par une police de System Système @@ -2745,6 +2769,8 @@ digits Malayalam Malayalam Reset OpenType Features Réinitialiser les fonctions OpenType Reset Panel Réinitialiser le panneau Reset Preferences Réinitialiser les préférences + Search Preferences Rechercher dans les préférences + No settings found Aucun réglage trouvé Reset Profiles Réinitialiser les profils Reset Rotate View Réinitialiser la rotation de l’affichage Rotate View to Selection Faire pivoter l’affichage selon la sélection @@ -2799,6 +2825,7 @@ digits Malayalam Malayalam Rotate Tool Outil Rotation Rotate View Tool Outil Faire pivoter l’affichage Trackpad Gesture to Rotate View Faire pivoter l’affichage avec un geste du trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Maintenir le raccourci d’un outil pour l’utiliser temporairement Rotate… Rotation… Rotation Rotation Roughen… Rugosité… @@ -3238,6 +3265,7 @@ digits Malayalam Malayalam Spaces Espaces Spacing Guides Repères d’espacement Invoke Distance Guides Appeler les repères de distance + Snap to Last Location Magnétisme du dernier emplacement Spacing: Espacement : Spanish Espagnol Specified Distance Distance définie @@ -3919,6 +3947,9 @@ axis Both Les deux Put back in the toolbar Remettre dans la barre d’outils Float Panel Rendre le panneau flottant Dock Panel Ancrer le panneau + Dock to Top Ancrer en haut + Dock to Bottom Ancrer en bas + Dock Control Bar Ancrer la barre de contrôle Drag to float the toolbar Faire glisser pour rendre la barre d’outils flottante Drag to the window's left edge to dock Faire glisser vers le bord gauche de la fenêtre pour ancrer Float Tool Group Rendre le groupe d’outils flottant @@ -4366,6 +4397,7 @@ axis Both Les deux @msg nothing to save: the document has no visible art rien à enregistrer : le document n’a aucune illustration visible @msg nothing to undo rien à annuler @msg no history to clear aucun historique à effacer +@msg no history state {v} aucun état d’historique {v} @msg nothing to warp rien à déformer @msg nothing to wrap rien à envelopper @msg object {0} cannot have children l’objet {0} ne peut pas avoir d’enfants @@ -4612,6 +4644,7 @@ axis Both Les deux @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} image impossible à décoder, affichée sous forme de cadre barré d’une croix|{n} images impossibles à décoder, affichées sous forme de cadres barrés d’une croix @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} image liée modifiée sur le disque : Mettre à jour les liens affiche la nouvelle version|{n} images liées modifiées sur le disque : Mettre à jour les liens affiche les nouvelles versions @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk {n} fichier lié modifié sur le disque mis à jour|{n} fichiers liés modifiés sur le disque mis à jour +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings le document définit un fond perdu que ce fichier PDF/X omet : activez « Utiliser les paramètres de fond perdu du document » @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} exporté avec {n} remarque : {first}|{what} exporté avec {n} remarques : {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} fichier de polices se trouve maintenant dans {folder}|{n} fichiers de polices se trouvent maintenant dans {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} fichier de polices se trouve maintenant dans {folder} ; {failed} n’ont pas pu être ajoutés : {reason}|{n} fichiers de polices se trouvent maintenant dans {folder} ; {failed} n’ont pas pu être ajoutés : {reason} @@ -4744,6 +4777,7 @@ axis Both Les deux ICF Bottom/Left ICF bas/gauche @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` est la référence d’étape {r}, que seuls `run` et `command_batch` résolvent @msg `ids` must be an array of object ids, not {v} `ids` doit être une liste d’identifiants d’objet, pas {v} +@msg `colors` must be an array of colors, not {v} `colors` doit être une liste de couleurs, pas {v} @msg `corners` must be an array of anchor indices, not {v} `corners` doit être une liste d’indices de points d’ancrage, pas {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral) @@ -5783,6 +5817,33 @@ axis Y Axis Axe Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy` @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? + ID: {id} ID : {id} + Version: {version} Version : {version} + File SHA-256: {hash} SHA-256 du fichier : {hash} + Read the objects it is given Lire les objets qui lui sont transmis + Change, add and delete objects in the document Modifier, ajouter et supprimer des objets du document + Keep data of its own with the document Conserver ses propres données avec le document + Change how the objects its effect is applied to are drawn Modifier le rendu des objets auxquels son effet est appliqué + Write the document as a file in its own format Écrire le document dans un fichier à son propre format + Show its progress, see Cancel and write log lines Afficher sa progression, détecter l’annulation et écrire des lignes de journal +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `progress` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} le module importe `env.{name}` avec un autre type que celui de l’hôte : celui de l’hôte est {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que `env.vc_progress`, `env.vc_cancelled` et `env.vc_log` (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} +@msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations +@msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un module externe de type `{_1}` a besoin de l’autorisation `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` au plus {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` +@msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg unknown permission `{shown}` autorisation inconnue `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@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é # Actions panel: action sets (#3712). New Set… Nouveau jeu… New Set Nouveau jeu @@ -5791,6 +5852,19 @@ axis Y Axis Axe Y Action Options… Options de script… Action Options Options de script Playback Options… Options d’exécution… + Insert Stop… Insérer un arrêt… + Insert Stop Insérer un arrêt + Record Stop Enregistrer un arrêt + Allow Continue Autoriser la poursuite + Continue Continuer + Continue Action Continuer le script + Delete Action Step Supprimer l’étape du script + Press Play Current Selection in the Actions panel to go on. Pour continuer, cliquez sur Exécuter la sélection actuelle dans le panneau Scripts. + Stop: {message} Arrêt : {message} +@msg no action is waiting at a stop aucun script n’attend à un arrêt +@msg message must be text message doit être du texte +@msg allowContinue must be true or false allowContinue doit valoir true ou false +@msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt Playback Options Options d’exécution Accelerated Accélérée Step by Step Pas à pas @@ -6229,6 +6303,7 @@ breakText Glyphs Glyphes Gap 5 Écart 5 @msg {_1}: they came in as the page's art {_1} : ils ont été importés comme le dessin de la page +@msg warped text objects that came in as drawn art, not as editable type: {_1} objets texte déformés importés comme dessin et non comme texte modifiable : {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} les objets dessinés différemment de la page du fichier lui-même ont été importés comme le dessin de la page : {_1} @msg You must choose a side pattern. Vous devez choisir un motif latéral. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e1af82a3a..0f212f4fd 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -148,6 +148,11 @@ 32 bit 32 बिट 32 bits/pixel 32 बिट्स/पिक्सेल 3D and Materials 3D और मटीरियल्स + Plane समतल + Extrude एक्सट्रूड + Revolve रिवॉल्व + Inflate फुलाएँ + Select objects, then pick an object type to give them a 3D effect. ऑब्जेक्ट्स सिलेक्ट करें, फिर उन्हें 3D इफ़ेक्ट देने के लिए एक ऑब्जेक्ट टाइप चुनें। 3D Box 3D बॉक्स 3D Box Tool 3D बॉक्स टूल 3D Lighting Preset 3D लाइटिंग प्रीसेट @@ -745,6 +750,7 @@ Clear Events घटनाओं को साफ़ करें Clear Guides गाइड्स हटाएँ Clear History हिस्ट्री साफ़ करें + New Document from State स्थिति से नया दस्तावेज़ Clear Overrides साफ़ ओवरराइड्स Clear Recent Files हाल की फ़ाइलें साफ़ करें Clear Selection सिलेक्शन साफ़ करें @@ -1299,6 +1305,9 @@ Do Not Scale स्केल न करें Do you want to save the changes you made to “{name}”? क्या आप "{name}" में किए गए परिवर्तनों को सेव करना चाहते हैं? Dock Panel पैनल डॉक करें + Dock to Top ऊपर डॉक करें + Dock to Bottom नीचे डॉक करें + Dock Control Bar नियंत्रण बार डॉक करें Document डॉक्यूमेंट Document Color Mode डॉक्यूमेंट रंग मोड Document Health Check डॉक्यूमेंट स्वास्थ्य जांच @@ -1631,6 +1640,8 @@ Export for Screens Settings स्क्रीन सेटिंग के लिए निर्यात Export for Screens… स्क्रीन के लिए निर्यात… Export Keyboard Shortcuts… कीबोर्ड शॉर्टकट निर्यात करें… + Export Keyboard Shortcuts as Text… कीबोर्ड शॉर्टकट टेक्स्ट के रूप में निर्यात करें… + Export Text… टेक्स्ट निर्यात करें… Export Layer निर्यात लेयर Export Library लाइब्रेरी Export Library… लाइब्रेरी निर्यात करें… @@ -1965,6 +1976,8 @@ Grays ग्रे Grays and Neutrals ग्रे और तटस्थ Grayscale ग्रेस्केल + High Color अधिक रंग + Low Color कम रंग Grayscale images ग्रेस्केल इमेज Grayscale Images ग्रे स्केल इमेज Greek & Cyrillic ग्रीक & Cyrillic @@ -2248,6 +2261,7 @@ Invert Pie पाई उलटें Inverted Round उल्टे दौर Invoke Distance Guides दूरी गाइड्स दिखाएँ + Snap to Last Location अंतिम स्थान पर स्नैप करें Isolate पृथक Isolate Blending ब्लेंडिंग अलग करें Isolate Group अलग समूह @@ -3290,6 +3304,19 @@ Play Action खेल कार्रवाई Play Current Selection वर्तमान सिलेक्शन Play: चलाएँ: + Insert Stop… स्टॉप जोड़ें… + Insert Stop स्टॉप जोड़ें + Record Stop स्टॉप रिकॉर्ड करें + Allow Continue जारी रखने दें + Continue जारी रखें + Continue Action एक्शन जारी रखें + Delete Action Step एक्शन का चरण हटाएं + Press Play Current Selection in the Actions panel to go on. आगे बढ़ने के लिए एक्शन्स पैनल में वर्तमान सिलेक्शन प्ले करें दबाएँ। + Stop: {message} स्टॉप: {message} +@msg no action is waiting at a stop कोई एक्शन स्टॉप पर प्रतीक्षा नहीं कर रहा है +@msg message must be text message टेक्स्ट होना चाहिए +@msg allowContinue must be true or false allowContinue true या false होना चाहिए +@msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता Playback Options प्लेबैक ऑप्शन्स Playback Options… प्लेबैक ऑप्शन्स… Plug-in Info प्लगइन जानकारी @@ -3597,6 +3624,14 @@ Relink दोबारा लिंक करें Relink Linked Files to the Document लिंक्ड फ़ाइलें डॉक्यूमेंट से दोबारा लिंक करें Relink to Folder… फ़ोल्डर से दोबारा लिंक करें… + Each Link Separately हर लिंक अलग से + Selected Instances चुने गए इंस्टेंस + All Instances सभी इंस्टेंस + Missing Links from This Folder इस फ़ोल्डर से मिसिंग लिंक +@msg scope {v} is not each|selected|all|missingInFolder scope {v}, each|selected|all|missingInFolder में से नहीं है +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each और paths साथ में दिए जाते हैं: paths में ids और path वाली एंट्री होती हैं, ids, path और folder की जगह +@msg paths entry {i} needs path and ids (object ids no other entry has) paths की एंट्री {i} को path और ids चाहिए (ऐसे ऑब्जेक्ट ids जो किसी दूसरी एंट्री में न हों) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder, path लेता है: उस फ़ाइल के फ़ोल्डर में बाकी मिसिंग लिंक खोजे जाते हैं Relink… दोबारा लिंक करें… Reload Plug-ins प्लग-इन फिर से लोड करें Remember Import Matches याद रखें आयात मैच @@ -3669,6 +3704,7 @@ Replace Missing Fonts… अनुपलब्ध फ़ॉन्ट रिप्लेस करें… Replace Spine स्पाइन रिप्लेस करें Replace Symbol सिंबल रिप्लेस करें + Reset Transformation रूपांतरण रीसेट करें Replace With Font इस फ़ॉन्ट से रिप्लेस करें Replace With Font From इस स्रोत के फ़ॉन्ट से रिप्लेस करें Replace with Top Object सबसे ऊपर के ऑब्जेक्ट से रिप्लेस करें @@ -3699,6 +3735,8 @@ Reset Painting पेंटिंग रीसेट करें Reset Panel पैनल रीसेट करें Reset Preferences प्रेफ़रेंसेज़ रीसेट करें + Search Preferences प्रेफ़रेंसेज़ में खोजें + No settings found कोई सेटिंग नहीं मिली Reset Printing and Proofing मुद्रण और प्रूफिंग रीसेट करें Reset Profiles प्रोफ़ाइल रीसेट करें Reset Rotate View व्यू घुमाएँ रीसेट करें @@ -4803,6 +4841,7 @@ Tracing the colors… रंग अनुरेखण… Tracking ट्रैकिंग Trackpad Gesture to Rotate View दृश्य घुमाने के लिए ट्रैकपैड जेस्चर + Hold a Tool Shortcut to Use the Tool Temporarily किसी टूल का शॉर्टकट दबाए रखकर उसे अस्थायी रूप से उपयोग करें Trademark Symbol ट्रेडमार्क सिंबल Transform रूपांतरण करें Transform Again फिर से बदलना @@ -5073,6 +5112,8 @@ Video 3840×2160 वीडियो 3840 × 2160 Video Banner 2560×1440 वीडियो बैनर 2560 × 1440 Video Ruler Pixel Aspect Ratio: वीडियो शासक पिक्सेल दृष्टि अनुपात: + Fade Region Outside Artboard आर्टबोर्ड के बाहर का क्षेत्र धुंधला करें + Update While Dragging खींचते समय अपडेट करें Video Thumbnail 1280×720 वीडियो थंबनेल 1280 × 720 View व्यू View {n} {n} @@ -5173,6 +5214,33 @@ @id pathfinder.add मिलाएँ @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): उपयोगकर्ता की सहमति मिलने पर इसे `allowHeavy: true` से इंस्टॉल करें @msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है? + ID: {id} ID: {id} + Version: {version} संस्करण: {version} + File SHA-256: {hash} फ़ाइल SHA-256: {hash} + Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना + Change, add and delete objects in the document डॉक्यूमेंट में ऑब्जेक्ट्स बदलना, जोड़ना और हटाना + Keep data of its own with the document अपना डेटा डॉक्यूमेंट के साथ रखना + Change how the objects its effect is applied to are drawn जिन ऑब्जेक्ट्स पर उसका इफ़ेक्ट लगा है, उनके बनाए जाने का तरीका बदलना + Write the document as a file in its own format डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना + Show its progress, see Cancel and write log lines अपनी प्रगति दिखाना, रद्द करने का अनुरोध देखना और लॉग पंक्तियाँ लिखना +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `progress` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें +@msg the module imports `env.{name}` with another type than the host's: it is {signature} मॉड्यूल `env.{name}` को होस्ट से अलग प्रकार के साथ आयात करता है: होस्ट का प्रकार {signature} है +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `progress` के साथ उपयोग कर सकता है +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल `env.vc_progress`, `env.vc_cancelled` और `env.vc_log` (प्लग-इन API स्तर 2) आयात कर सकता है, और कोई WASI, फ़ाइल या नेटवर्क नहीं +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है +@msg `grant` must be a list of permission names `grant` अनुमति नामों की सूची होनी चाहिए +@msg `grant` names an unknown permission `{shown}` `grant` एक अज्ञात अनुमति `{shown}` बताता है +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` प्रकार के प्लग-इन को `{_2}` अनुमति चाहिए +@msg at most {MAX_PERMISSIONS} `permissions` अधिकतम {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` अनुमति `{shown}` `{_1}` प्रकार के प्लग-इन पर लागू नहीं होती +@msg permission `{shown}` is listed twice अनुमति `{shown}` दो बार सूचीबद्ध है +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता +@msg unknown permission `{shown}` अज्ञात अनुमति `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ @msg “{name}” is not a file "{name}" एक फ़ाइल नहीं है @msg ({note}) ({more}) ({note}) ({more}) @msg (fonts not copied: {fonts}) (फ़ॉन्ट कॉपी नहीं हुए: {fonts}) @@ -5238,9 +5306,9 @@ @msg {k} is too long (at most {MAX_TEXT} characters) {k} बहुत लंबा है (अधिकांश {MAX_TEXT} वर्णों पर) @msg {k} must be {{size?, position?}} {k} को {{size?, position?}} होना चाहिए @msg {k} must be > 0 {k} होना चाहिए > 0 -@msg {k} must be a number (pt) or a length such as "210 mm" {k} को एक नंबर (pt) या "210 मिमी" जैसी लंबाई होना चाहिए। +@msg {k} must be a number (pt) or a length such as "210 mm" {k} को एक नंबर (pt) या "210 mm" जैसी लंबाई होना चाहिए। @msg {k} must be a number from {min} to {max} {k} को {min} से {max} के बीच की संख्या होना चाहिए -@msg {k} must be a number or "auto" {k} एक नंबर या "ऑटो" होना चाहिए। +@msg {k} must be a number or "auto" {k} एक नंबर या "auto" होना चाहिए। @msg {k} must be a string {k} एक स्ट्रिंग होना चाहिए @msg {k} must be text {k} को टेक्स्ट होना चाहिए @msg {k} must be true or false {k} को सच या झूठा होना चाहिए @@ -5430,7 +5498,7 @@ @msg `{what}` must be text `{what}` को टेक्स्ट होना चाहिए @msg `a` and `b` must be two different paths `a` और `b` को दो अलग-अलग पाथ होना चाहिए @msg `align` is start, center or end `align` शुरू, केंद्र या अंत है -@msg `align` must be "left", "center" or "right", not {other} `align` होना चाहिए "left", "सेंटर" या "right", नहीं {other} +@msg `align` must be "left", "center" or "right", not {other} `align` होना चाहिए "left", "center" या "right", नहीं {other} @msg `anchors` must be an array of [subpath, anchor] pairs `anchors` को [subpath, एंकर] जोड़े की एक सारणी होना चाहिए @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {k} नहीं @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {v} नहीं @@ -5482,13 +5550,13 @@ @msg `diameter` must be from 0.25 to 200 pt `diameter` को 0.25 से 200 pt होना चाहिए @msg `digits` must be one of {_1} `digits` को {_1} में से एक होना चाहिए @msg `dimImages` is a percentage or false `dimImages` एक प्रतिशत या झूठी है -@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` को "ऑटो", "leftToRight" या "rightToLeft" होना चाहिए +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` को "auto", "leftToRight" या "rightToLeft" होना चाहिए @msg `dither` applies to gradient fills (paint.setFill) `dither` gradient fills (paint.setFill) पर लागू होता है @msg `dither` applies to gradient fills (stroke: false) `dither` gradient fills (स्ट्रोक: झूठी) पर लागू होता है @msg `dither` must be true or false `dither` को सच या झूठा होना चाहिए @msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} bytes (limit {MAX_DOCUMENT_DATA}) है। @msg `each` is the value one design stands for, more than 0 `each` एक डिज़ाइन का मान है, जो 0 से अधिक होना चाहिए -@msg `end` must be "start" or "end" `end` को " start" या "end" होना चाहिए। +@msg `end` must be "start" or "end" `end` को "start" या "end" होना चाहिए। @msg `ends` applies to joining exactly two paths `ends` वास्तव में दो पाथ में शामिल होने के लिए लागू होता है @msg `event` must be one of {_1} `event` is one of the {_1} @msg `every` must be "all", "odd" or "even", not {v} `every` होना चाहिए "all", "odd" या "even", नहीं {v} @@ -5521,6 +5589,7 @@ @msg `id` must be a node id `id` एक नोड आईडी होना चाहिए @msg `id` must be an object id, not {v} `id` एक ऑब्जेक्ट आईडी होना चाहिए, नहीं {v} @msg `ids` must be an array of object ids, not {v} `ids` को ऑब्जेक्ट आईडी की ऐरे होना चाहिए, {v} नहीं +@msg `colors` must be an array of colors, not {v} `colors` को रंगों की ऐरे होना चाहिए, {v} नहीं @msg `ids` must name Objects on Path groups or objects in them `ids` को ऑब्जेक्ट्स ऑन पाथ समूह या उनके ऑब्जेक्ट बताने चाहिए @msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` में अधिकतम {MAX_INSPECT_IDS} आईडी हो सकती हैं @msg `imageMap` must be none, rectangle or polygon `imageMap` होना चाहिए कोई नहीं, आयत या बहुभुज @@ -5546,9 +5615,9 @@ @msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` में से एक होना चाहिए: आयत, अंडाकार, बहुभुज, स्टार, रिंग, तीर, क्रॉस, दिल, गियर, बुलबुला @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` को spotColors, colorEffects, rgbImages, cmykImages या grayImages होना चाहिए @msg `kinds` must be a list of issue kinds `kinds` को जारी करने के प्रकार की सूची होना चाहिए -@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` होना चाहिए "one", "hard" या "soft" होना चाहिए। +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` "none", "hard" या "soft" होना चाहिए। @msg `label` must be a non-empty name of at most 200 characters `label` को 200 वर्णों में से एक गैर-खाली नाम होना चाहिए -@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` एक भाषा का नाम या टैग होना चाहिए, जैसे "तुर्की" या "tr" +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` एक भाषा का नाम या टैग होना चाहिए, जैसे "Turkish" या "tr" @msg `leading` must be from 0.1 to 5000, or null for Auto `leading` को 0.1 से 5000 तक होना चाहिए, या ऑटो के लिए शून्य होना चाहिए @msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` को "romanBaseline" या "emBoxTop" होना चाहिए @msg `length` must be a number of at most 1000000 pt either way `length` को 1000000 pt में से एक होना चाहिए। @@ -5596,9 +5665,9 @@ @msg `rule` must be nonZero or evenOdd `rule` होना चाहिए nonZero or evenOdd होना चाहिए @msg `sampleSize` must be 1, 3 or 5 `sampleSize` होना चाहिए 1, 3 या 5 @msg `scale` must be a number in (0, 100] `scale` को (0, 100] के बीच की संख्या होना चाहिए -@msg `scope` must be "selection" or "document", not {other} `scope` को "सिलेक्शन" या "दस्ताव" होना चाहिए, न कि {other} +@msg `scope` must be "selection" or "document", not {other} `scope` को "selection" या "document" होना चाहिए, न कि {other} @msg `script` must be a string `script` एक स्ट्रिंग होना चाहिए -@msg `segment` must be "straight" or "curved" `segment` को "सीधे" या "ठीक" होना चाहिए। +@msg `segment` must be "straight" or "curved" `segment` को "straight" या "curved" होना चाहिए। @msg `select` must be true or false `select` को true या false होना चाहिए @msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` श्रृंखला सूचकांकों की एक गैर-खाली सूची है @msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` को `type` या `valueAxis` की आवश्यकता होती है @@ -5609,7 +5678,7 @@ @msg `source` and `target` are the same object `source` और `target` एक ही ऑब्जेक्ट है @msg `source` must be `folder` or `datasets` `source` होना चाहिए `folder` या `datasets` होना चाहिए @msg `spacing` must be a number `spacing` होना चाहिए -@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` को कई अंक, 0 या उससे अधिक, या "ऑटो" होना चाहिए। +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` को कई अंक, 0 या उससे अधिक, या "auto" होना चाहिए। @msg `start` must be a finite number `start` को परिमित संख्या होना चाहिए @msg `starts` must be an array of anchor indices (or nulls) `starts` को एंकर इंडेक्स (या null) की ऐरे होना चाहिए @msg `status` must be linked, embedded, missing or modified, or a list of them `status` को लिंक, एम्बेडेड, लापता या संशोधित किया जाना चाहिए, या उनमें से एक सूची @@ -5624,14 +5693,14 @@ @msg `symbol` must be a symbol name `symbol` एक सिंबल नाम होना चाहिए @msg `t` must be a number from -0.98 to 0.98 `t` को -0.98 से 0.98 के बीच की संख्या होना चाहिए @msg `t` must be from 0 to 1 `t` 0 से 1 तक होना चाहिए -@msg `target` must be "object" or "contents", not {v} `target` को "object" या "content" होना चाहिए, नहीं {v} +@msg `target` must be "object" or "contents", not {v} `target` को "object" या "contents" होना चाहिए, नहीं {v} @msg `tint` applies to global and spot colours `tint` वैश्विक और स्पॉट रंगों पर लागू होता है @msg `tint` goes with a global or spot `swatch` `tint` एक वैश्विक या स्थान `swatch` के साथ चला जाता है @msg `tint` must be a number (%) `tint` एक नंबर होना चाहिए (%) @msg `tint` needs a global or spot colour `tint` को वैश्विक या स्पॉट रंग की आवश्यकता होती है @msg `to` is already threaded `to` पहले से ही पिरोया है @msg `to` is the design's new name `to` डिजाइन का नया नाम है -@msg `to` must be "corner" or "smooth" `to` होना चाहिए "कॉर्नर" या "चिकना" होना चाहिए। +@msg `to` must be "corner" or "smooth" `to` "corner" या "smooth" होना चाहिए। @msg `to` must be [x, y] `to` होना चाहिए [x, y]] @msg `to` must be a closed path, area type or type on a path other than `from` `to` को बंद पाथ, एरिया टाइप या पाथ पर टाइप होना चाहिए और `from` से अलग होना चाहिए @msg `to` must be a whole number `to` एक पूरी संख्या होना चाहिए @@ -5803,7 +5872,7 @@ @msg Angles require two straight segments कोणों को दो सीधे खंडों की आवश्यकता होती है @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art या type @msg appearance must be flat|center|edge appearance को flat|center|edge होना चाहिए -@msg apply is "fill" or "stroke", not `{other}` लागू "भर" या "स्ट्रोक" है, नहीं `{other}` +@msg apply is "fill" or "stroke", not `{other}` apply "fill" या "stroke" है, नहीं `{other}` @msg area must be a closed polygon of 3 to 100000 [x, y] points area: क्षेत्र 3 से 100000 [x, y] अंक के एक बंद बहुभुज होना चाहिए @msg area type {_1} is named twice क्षेत्र के प्रकार {_1} को दो बार नाम दिया गया है @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise फ्रेम में क्षेत्र प्रकार फ़ॉन्ट की एक पंक्ति के लिए बहुत कम है, यह लंबा फ्रेम ({_1}) में दिखाता है: उनका प्रकार अन्यथा नहीं दिखाई देगा @@ -5979,7 +6048,7 @@ @msg compound paths can only contain paths यौगिक पाथ में केवल पाथ हो सकते हैं @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes मिश्रित आकार पाथ, यौगिक पाथ, समूह, मिश्रित आकार, प्रकार, मिश्रण और लिफाफे लेते हैं @msg compressed (RLE) bitmaps can't be read yet संकुचित (RLE) bitmaps अभी तक पढ़ा नहीं जा सकता है -@msg connect end must be "start" or "end" कनेक्ट अंत "शुरू" या "अंत" होना चाहिए +@msg connect end must be "start" or "end" कनेक्ट अंत "start" या "end" होना चाहिए @msg connect needs a path other than the one extended connect: कनेक्ट एक विस्तारित के अलावा एक पाथ की जरूरत है @msg connect needs id connect: कनेक्ट करने की जरूरत आईडी @msg connect path has no open end कनेक्ट पाथ का कोई खुला अंत नहीं है @@ -6091,7 +6160,7 @@ @msg empty header in column {_1} कॉलम में खाली हैडर {_1} @msg empty path खाली पाथ @msg encoding `{_1}`: utf8 or utf16 एन्कोडिंग `{_1}`: utf8 or utf16 -@msg ends must be two of "first" and "last" ends: समाप्त होता है दो "पहली" और "अंतिम" होना चाहिए। +@msg ends must be two of "first" and "last" ends: समाप्त होता है दो "first" और "last" होना चाहिए। @msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} @msg EPS flattener: {e} ईपीएस flattener: {e} @msg EPS options: {e} ईपीएस ऑप्शन्स: {e} @@ -6232,7 +6301,7 @@ @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it ग्रेडिएंट "{name}" को फ़ाइल में परिभाषित नहीं किया गया है: इसके लिए एक सादे रंग खड़ा है @msg gradient must be "objects" or "mesh" gradient: ग्रेडिएंट "objects" या "mesh" होना चाहिए @msg graphs come in as plain art, without their data or settings ग्राफ़ अपने डेटा या सेटिंग्स के बिना सादे कला के रूप में आते हैं -@msg grays must be "profile" or "black", not `{g}` grays: ग्रे "प्रोफ़ाइल" या "काला" होना चाहिए, नहीं `{g}` +@msg grays must be "profile" or "black", not `{g}` grays: ग्रे "profile" या "black" होना चाहिए, नहीं `{g}` @msg gridColors must be a preset name or two colours gridColors को प्रीसेट नाम या दो रंग होना चाहिए @msg gridSize must be small, medium or large gridSize को small, medium या large होना चाहिए @msg group must be the id of a Shaper Group group: समूह को एक शेपर ग्रुप का आईडी होना चाहिए @@ -6351,12 +6420,12 @@ @msg Join: a subpath went missing जॉइन: कोई सबपाथ नहीं मिला @msg justify must be auto|left|center|right|justifyAll justify को auto|left|center|right|justifyAll होना चाहिए @msg kbps must be a positive number, not {kbps} kbps एक सकारात्मक संख्या होना चाहिए, नहीं {kbps} -@msg kerning must be a number, "auto", or "optical" kerning एक संख्या, "ऑटो", या "ऑप्टिकल" होना चाहिए +@msg kerning must be a number, "auto", or "optical" kerning एक संख्या, "auto", या "optical" होना चाहिए @msg keywords must be a list of words or a comma-separated string keywords: कीवर्ड शब्दों की एक सूची होना चाहिए या एक अल्पसंख्यक स्ट्रिंग होना चाहिए @msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords: कीवर्ड नियंत्रण वर्णों के बिना, अधिकांश {MAX_KEYWORD} वर्णों पर होना चाहिए @msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind: टाइप 1, 2 या 3 होना चाहिए, या प्रीसेट `name` देना चाहिए (see पर्सपेक्टिव.presets.list) @msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind को graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle या paraStyle होना चाहिए -@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout: नॉकआउट को "on", "off", "न्यूट्रल" या एक बोल, न {v} होना चाहिए +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout: नॉकआउट को "on", "off", "neutral" या एक बोल, न {v} होना चाहिए @msg lang must be one of {_1} lang: लैंग को {_1} में से एक होना चाहिए @msg larger than 256 MB 256 MB से अधिक @msg layer `{name}` has no art to export लेयर `{name}` को निर्यात करने के लिए कोई कला नहीं है @@ -6366,7 +6435,7 @@ @msg layers go only at the top level or in layers, objects in layers and groups लेयरें केवल शीर्ष स्तर पर या लेयर्स में, लेयर्स और समूहों में ऑब्जेक्टएं @msg layers must be true or false layers: लेयरें सत्य या झूठी होनी चाहिए @msg layout must be gridByRow, gridByColumn, row or column layout को gridByRow, gridByColumn, row या column होना चाहिए -@msg leading must be a number or "auto" leading: अग्रणी एक नंबर या "ऑटो" होना चाहिए +@msg leading must be a number or "auto" leading: अग्रणी एक नंबर या "auto" होना चाहिए @msg left/right must be non-negative widths बाएं / दाएं गैर-नकारात्मक चौड़ाई होना चाहिए @msg level `{t}`: 2 or 3 स्तर `{t}`: 2 या 3 @msg library panel `{given}` isn't missing its library लाइब्रेरी पैनल `{given}` अपने लाइब्रेरी को याद नहीं कर रहा है @@ -6600,6 +6669,7 @@ @msg no guide selected कोई गाइड सिलेक्टेड नहीं @msg no guide selected (or give an index) कोई गाइड नहीं चुना गया (या एक सूचकांक देना) @msg no history to clear स्पष्ट करने के लिए कोई इतिहास नहीं +@msg no history state {v} कोई हिस्ट्री स्थिति {v} नहीं @msg no images in the folder फ़ोल्डर में कोई इमेज नहीं @msg no interaction in progress प्रगति में कोई बातचीत नहीं @msg no item at index {_1} इंडेक्स में कोई आइटम नहीं {_1} @@ -6845,6 +6915,7 @@ @msg objects can't be nested more than 32 deep ऑब्जेक्ट्स को 32 से अधिक गहराई तक घोंसला नहीं किया जा सकता है @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path एक मिश्रण, लिफ़ाफ़ाफ़ा, दोहराने या अंतर्जातीय समूह के अंदर ऑब्जेक्ट्स को एक पाथ से जोड़ा नहीं जा सकता है @msg objects must be none, move or copy objects: ऑब्जेक्टएं कोई नहीं, चाल या प्रतिलिपि होना चाहिए +@msg warped text objects that came in as drawn art, not as editable type: {_1} मोड़े गए टेक्स्ट ऑब्जेक्ट जो संपादन योग्य टेक्स्ट के बजाय कला के रूप में आए: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} उन ऑब्जेक्ट्स को जो फ़ाइल के स्वयं के पृष्ठ से अलग-अलग तरीके से आकर्षित होते हैं, उनकी कला के रूप में आया: {_1} @msg objects were left out ऑब्जेक्ट्स को छोड़ दिया गया था @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है @@ -6986,7 +7057,7 @@ @msg procedures nest too deeply प्रक्रियाओं घोंसला बहुत गहराई से @msg procedures nested too deeply प्रक्रियाओं ने बहुत गहराई से घोंसले @msg quotes must be {double?, single?} quotes को {double?, single?} होना चाहिए -@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" उद्धरण.{k} एक उद्घाटन और एक समापन उद्धरण होना चाहिए, उदाहरण के लिए """"" +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" उद्धरण.{k} एक उद्घाटन और एक समापन उद्धरण होना चाहिए, उदाहरण के लिए "“”" @msg range `{s}` is empty श्रेणी `{s}` खाली है @msg range `{s}`: `{part}` names no number सीमा `{s}`: `{part}` कोई संख्या नहीं बताता @msg range `{s}`: `{part}` runs backwards रेंज `{s}`: `{part}` पीछे चल रहा है @@ -7167,7 +7238,7 @@ @msg Show All Hidden Attributes: nothing is hidden Show all Hidden Attributes: कुछ भी छिपा नहीं है @msg show must be true or false show: प्रदर्शन को सच या झूठा होना चाहिए @msg Simplify Anchors: the paths have too many anchors to simplify at once एंकर को सरलीकृत करें: पाथ में एक बार में सरल बनाने के लिए कई एंकर हैं -@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: आकार "छोटा", "मध्यम", "बड़ा" या 0 से अधिक अंक {_1} तक होना चाहिए +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: आकार "small", "medium", "large" या 0 से अधिक अंक {_1} तक होना चाहिए @msg size must be positive size: आकार सकारात्मक होना चाहिए @msg sizeMode is "fixed" or "pressure", not {other} sizeMode को "fixed" या "pressure" होना चाहिए, {other} नहीं @msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode को "fixed" या "pressure" होना चाहिए, `{other}` नहीं @@ -7177,10 +7248,10 @@ @msg sort `{x}`: name, kind or status टाइप `{x}`: नाम, प्रकार या स्थिति @msg source must be the id of a Shaper Group's original shape source: स्रोत एक शेपर ग्रुप के मूल आकार का आईडी होना चाहिए @msg source paths are too large for Shaper स्रोत पाथ शेपर के लिए बहुत बड़े हैं -@msg span must be "object" or "selection", got {v} span: स्पैन को "ऑब्जेक्ट" या "सिलेक्शन" होना चाहिए, {v} मिला +@msg span must be "object" or "selection", got {v} span: स्पैन को "object" या "selection" होना चाहिए, {v} मिला @msg span: "selection" places a `gradient`, not a swatch span: "selection" एक `gradient` रखता है, स्वॉच नहीं @msg span: "selection" places a gradient: give `gradient` span: "selection" ग्रेडिएंट रखता है: `gradient` दें -@msg span: "selection" places a linear or radial gradient, not a freeform one स्पैन: "सिलेक्शन" एक रैखिक या रेडियल ग्रेडिएंट रखता है, एक फ्रीफॉर्म नहीं एक +@msg span: "selection" places a linear or radial gradient, not a freeform one स्पैन: "selection" एक रैखिक या रेडियल ग्रेडिएंट रखता है, एक फ्रीफॉर्म नहीं एक @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" खुद ग्रेडिएंट रखता है: `start` और `end` न दें (वे उसे डॉक्यूमेंट निर्देशांकों पर रख देते हैं) @msg speed must be accelerated, step or pause speed: गति को तेज, कदम या रोकें होना चाहिए @msg Split Into Grid: select paths large enough for the grid स्प्लिट इनो ग्रिड: ग्रिड के लिए काफी बड़े पाथ सिलेक्ट करें @@ -7341,7 +7412,6 @@ @msg the library holds {MAX_GROUPS} groups already लाइब्रेरी में पहले से ही {MAX_GROUPS} समूह हैं। @msg the library holds {MAX_ITEMS} items of this kind already लाइब्रेरी पहले से ही इस तरह के {MAX_ITEMS} आइटम रखता है @msg the library item is damaged लाइब्रेरी आइटम क्षतिग्रस्त है -@msg the module imports `{_1}.{_2}`, but plug-ins get no host imports (no WASI, files or network) मॉड्यूल आयात `{_1}.{_2}`, लेकिन प्लग-इन को कोई होस्ट आयात नहीं मिलता है (कोई WASI, फ़ाइल या नेटवर्क नहीं) @msg the module must export its linear memory as `memory` मॉड्यूल को अपनी रैखिक मेमोरी `memory` के रूप में एक्सपोर्ट करनी चाहिए @msg the name is empty नाम खाली है @msg the object already has an opacity mask ऑब्जेक्ट पहले से ही एक अपारदर्शिता मास्क है @@ -7733,6 +7803,7 @@ @msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} {n} डॉक्यूमेंट एक्सपोर्ट हुआ; {title} लिखा नहीं गया: {error}|{n} डॉक्यूमेंट एक्सपोर्ट हुए; {title} लिखा नहीं गया: {error} @msgplural Exported {n} document|Exported {n} documents {n} डॉक्यूमेंट एक्सपोर्ट हुआ|{n} डॉक्यूमेंट एक्सपोर्ट हुए @msgplural Exported {n} file to {folder}|Exported {n} files to {folder} {folder} में {n} फ़ाइल एक्सपोर्ट हुई|{folder} में {n} फ़ाइलें एक्सपोर्ट हुईं +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings डॉक्यूमेंट एक ब्लीड सेट करता है जिसे यह PDF/X फ़ाइल छोड़ देती है: "डॉक्यूमेंट की ब्लीड सेटिंग्स उपयोग करें" चालू करें @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} एक्सपोर्ट हुआ, {n} नोट के साथ: {first}|{what} एक्सपोर्ट हुआ, {n} नोट्स के साथ: {first} @msgplural Opened with {n} note: {first}|Opened with {n} notes: {first} {n} नोट के साथ खुला: {first}|{n} नोट्स के साथ खुला: {first} @msgplural Packaged {n} file ({note}) in {folder}|Packaged {n} files ({note}) in {folder} {folder} में {n} फ़ाइल ({note}) पैकेज हुई|{folder} में {n} फ़ाइलें ({note}) पैकेज हुईं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 82f75da72..a400c495f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -111,6 +111,11 @@ 32 bit 32 bit 32 bits/pixel 32 bit/pixel 3D and Materials 3D és anyagok + Plane Sík + Extrude Kihúzás + Revolve Forgatás tengely körül + Inflate Felfújás + Select objects, then pick an object type to give them a 3D effect. Jelöljön ki objektumokat, majd válasszon objektumtípust, hogy 3D-effektust kapjanak. 3D Ribbon 3D szalag 4 bit 4 bit 4-Up 4 nézet @@ -380,6 +385,8 @@ font-technology Variable Változó Show Cross Hairs Szálkereszt megjelenítése Show Video Safe Areas Biztonságos videoterületek megjelenítése Video Ruler Pixel Aspect Ratio: Videovonalzók képpont-méretaránya: + Fade Region Outside Artboard A rajztáblán kívüli terület elhalványítása + Update While Dragging Frissítés húzás közben Hide Video Rulers Videovonalzók elrejtése Background Contents Háttértartalom Background Contents: Háttértartalom: @@ -1073,6 +1080,9 @@ font-technology Variable Változó Do Not Scale Nincs méretezés Do you want to save the changes you made to “{name}”? Mented a módosításokat ebben: „{name}”? Dock Panel Panel dokkolása + Dock to Top Dokkolás felülre + Dock to Bottom Dokkolás alulra + Dock Control Bar Vezérlősáv dokkolása Document Dokumentum Document Color Mode Dokumentum színmódja Document Info Dokumentuminformáció @@ -1367,6 +1377,8 @@ font-technology Variable Változó Export for Screens Settings Exportálás képernyőkre – beállítások Export for Screens… Exportálás képernyőkre… Export Keyboard Shortcuts… Billentyűparancsok exportálása… + Export Keyboard Shortcuts as Text… Billentyűparancsok exportálása szövegként… + Export Text… Szöveg exportálása… Export Library Könyvtár exportálása Export Library… Könyvtár exportálása… Export Option Exportálási beállítás @@ -1742,6 +1754,8 @@ file-filter Templates Sablonok Grays Szürkék Grays and Neutrals Szürkék és semleges színek Grayscale Szürkeárnyalatos + High Color Sok szín + Low Color Kevés szín Grayscale images Szürkeárnyalatos képek Greek & Cyrillic Görög és cirill Green Zöld @@ -1844,6 +1858,7 @@ file-filter Templates Sablonok Step Backward Lépés vissza Step Forward Lépés előre Clear History Előzmények törlése + New Document from State Új dokumentum az állapotból Set History Limit… Előzménykorlát beállítása… History States Előzmények lépésszáma # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2300,6 +2315,9 @@ file-filter Templates Sablonok Live Paint Selection Tool Élőfestés-kijelölés Live Shape Properties Élő alakzat tulajdonságai Load Color Profile… Színprofil betöltése… + Load Character Styles… Karakterstílusok betöltése… + Load Paragraph Styles… Bekezdésstílusok betöltése… + Load All Styles… Az összes stílus betöltése… Remove Color Profile… Színprofil eltávolítása… Load Image into Clipboard Kép betöltése a vágólapra Load Metafile into Clipboard Metafájl betöltése a vágólapra @@ -3213,6 +3231,14 @@ mark-style Japanese Japán Relink Újracsatolás Relink Linked Files to the Document Csatolt fájlok újracsatolása a dokumentumhoz Relink to Folder… Újracsatolás mappából… + Each Link Separately Minden csatolás külön + Selected Instances Kijelölt példányok + All Instances Összes példány + Missing Links from This Folder Hiányzó csatolások ebből a mappából +@msg scope {v} is not each|selected|all|missingInFolder a scope {v} nem each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder a scope each és a paths együtt jár: a paths ids és path elemű bejegyzéseket sorol fel az ids, path és folder helyett +@msg paths entry {i} needs path and ids (object ids no other entry has) a paths {i} bejegyzéséhez path és ids kell (olyan objektumazonosítók, amelyek más bejegyzésben nincsenek) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links a scope missingInFolder path értéket vár: a többi hiányzó csatolást a fájl mappájában keresi Relink… Újracsatolás… Find Linked Files Csatolt fájlok keresése Find Linked Files in Folder… Csatolt fájlok keresése mappában… @@ -3265,6 +3291,7 @@ mark-style Japanese Japán 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 Replace Symbol Szimbólum cseréje + Reset Transformation Átalakítás visszaállítása Replace With Font Helyettesítő betűtípus Replace With Font From Helyettesítő betűtípus forrása System Rendszer @@ -3291,6 +3318,8 @@ mark-style Japanese Japán Reset Painting Painting visszaállítása Reset Panel Panel visszaállítása Reset Preferences Beállítások visszaállítása + Search Preferences Keresés a beállításokban + No settings found Nincs ilyen beállítás Reset Printing and Proofing Printing and Proofing visszaállítása Reset Profiles Profilok visszaállítása Reset Rotate View Nézetforgatás visszaállítása @@ -3371,6 +3400,7 @@ mark-style Japanese Japán Rotate View Nézet elforgatása Rotate View Tool Nézet elforgatása Trackpad Gesture to Rotate View Nézet elforgatása kézmozdulattal az érintőpadon + Hold a Tool Shortcut to Use the Tool Temporarily Eszköz ideiglenes használata a billentyűparancsa nyomva tartásával Rotate… Elforgatás… Rotation Elforgatás Rotation relative to: Elforgatás viszonyítási alapja: @@ -3881,6 +3911,7 @@ mark-style Japanese Japán Spacing Térköz Spacing Guides Térköz-segédvonalak Invoke Distance Guides Távolság-segédvonalak előhívása + Snap to Last Location Illesztés az utolsó helyre Spacing: Térköz: Spanish Spanyol Spatter Fröcskölés @@ -4581,6 +4612,7 @@ imageTrace Outline Körvonal @msg `groupColors` must be an array `groupColors`: tömb kell @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve az `id` lépéshivatkozás ({r}), ezt csak a `run` és a `command_batch` oldja fel @msg `ids` must be an array of object ids, not {v} `ids`: objektumazonosítók tömbje kell, ez nem az: {v} +@msg `colors` must be an array of colors, not {v} `colors`: színek tömbje kell, ez nem az: {v} @msg `library` is a library id or name (see {list}) a `library` könyvtárazonosító vagy -név lehet (lásd: {list}) @msg `mode` applies to freeform gradients a `mode` a szabad formájú színátmenetekre vonatkozik @msg `preserve` must be an object `preserve`: objektum kell @@ -5056,6 +5088,7 @@ imageTrace Outline Körvonal @msg nothing to save: the document has no visible art nincs mit menteni: a dokumentumban nincs látható grafika @msg nothing to undo nincs mit visszavonni @msg no history to clear nincs törölhető előzmény +@msg no history state {v} nincs {v} előzményállapot @msg nothing to warp nincs mit torzítani @msg nothing to wrap nincs mit körbefuttatni @msg object ids are non-negative integers, not {v} az objektumazonosító nemnegatív egész szám, ez nem az: {v} @@ -5125,6 +5158,7 @@ imageTrace Outline Körvonal @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes Nem dekódolható képek: {n}; áthúzott keretként látszanak @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions A lemezen módosult csatolt képek: {n}; a Csatolások frissítése az új változatokat mutatja @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Frissített, a lemezen módosult csatolt fájlok: {n} +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings a dokumentum kifutót állít be, amelyet ez a PDF/X-fájl kihagy: kapcsolja be „A dokumentum kifutóbeállításainak használata” beállítást @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Exportálva: {what}; megjegyzések: {n}, az első: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} Betűtípusfájlok száma: {n}, mappa: {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} Betűtípusfájlok száma: {n}, mappa: {folder}; nem sikerült hozzáadni: {failed} ({reason}) @@ -5771,6 +5805,33 @@ axis Y Axis Y tengely @msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy` @msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri? + ID: {id} Azonosító: {id} + Version: {version} Verzió: {version} + File SHA-256: {hash} A fájl SHA-256 értéke: {hash} + Read the objects it is given Olvashatja a kapott objektumokat + Change, add and delete objects in the document Módosíthat, hozzáadhat és törölhet objektumokat a dokumentumban + Keep data of its own with the document Saját adatokat tárolhat a dokumentummal + Change how the objects its effect is applied to are drawn Módosíthatja, hogyan rajzolódnak ki az objektumok, amelyekre az effektusa alkalmazva van + Write the document as a file in its own format Saját formátumú fájlba írhatja a dokumentumot + Show its progress, see Cancel and write log lines Megjelenítheti az előrehaladását, észlelheti a megszakítást és naplósorokat írhat +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a `progress` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket +@msg the module imports `env.{name}` with another type than the host's: it is {signature} a modul az `env.{name}` elemet a gazdaprogramétól eltérő típussal importálja: a gazdaprogram típusa {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a `progress` engedéllyel használhat +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak az `env.vc_progress`, `env.vc_cancelled` és `env.vc_log` elemet importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} az `api` értéke {_1}; ez a gazdaprogram az 1 és {API_LEVEL} közötti bővítmény-API-szinteket támogatja +@msg `grant` must be a list of permission names a `grant` értékének engedélynevek listájának kell lennie +@msg `grant` names an unknown permission `{shown}` a `grant` ismeretlen engedélyt nevez meg: `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` egy `{_1}` fajtájú bővítménynek szüksége van a(z) `{_2}` engedélyre +@msg at most {MAX_PERMISSIONS} `permissions` legfeljebb {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a(z) `{shown}` engedély nem vonatkozik `{_1}` fajtájú bővítményre +@msg permission `{shown}` is listed twice a(z) `{shown}` engedély kétszer szerepel +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg +@msg unknown permission `{shown}` ismeretlen engedély: `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott +@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 # Actions panel: action sets (#3712). New Set… Új készlet… New Set Új készlet @@ -5779,6 +5840,19 @@ axis Y Axis Y tengely Action Options… Művelet beállításai… Action Options Művelet beállításai Playback Options… Lejátszási beállítások… + Insert Stop… Megállás beszúrása… + Insert Stop Megállás beszúrása + Record Stop Megállás rögzítése + Allow Continue Folytatás engedélyezése + Continue Folytatás + Continue Action Művelet folytatása + Delete Action Step Műveletlépés törlése + Press Play Current Selection in the Actions panel to go on. A folytatáshoz nyomja meg a Műveletek panelen a Kijelölt művelet lejátszása gombot. + Stop: {message} Megállás: {message} +@msg no action is waiting at a stop egyetlen művelet sem várakozik megállásnál +@msg message must be text message: szöveg kell +@msg allowContinue must be true or false allowContinue: true vagy false kell +@msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet Playback Options Lejátszási beállítások Accelerated Gyorsított Step by Step Lépésenként @@ -6218,6 +6292,7 @@ breakText Glyphs Glifák Gap 5 Hézag 5 @msg {_1}: they came in as the page's art {_1}: az oldal rajzaként lettek importálva +@msg warped text objects that came in as drawn art, not as editable type: {_1} hajlított szövegobjektumok, amelyek rajzként, nem szerkeszthető szövegként lettek importálva: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} a fájl saját oldalától eltérően rajzolt objektumok az oldal rajzaként lettek importálva: {_1} @msg You must choose a side pattern. Válasszon oldalsó mintát. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7230b6bfa..0134649ea 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -414,6 +414,11 @@ Document Raster Effects Settings… Pengaturan efek raster dokumen… Vector Effects Efek vektor 3D and Materials 3D dan material + Plane Bidang + Extrude Ekstrusi + Revolve Revolusi + Inflate Gelembungkan + Select objects, then pick an object type to give them a 3D effect. Pilih objek, lalu pilih jenis objek untuk memberinya efek 3D. Extrude & Bevel… Ekstrusi & bevel… Revolve… Revolusi… Inflate… Gelembungkan… @@ -753,6 +758,9 @@ Put back in the toolbar Kembalikan ke bilah alat Float Panel Mengambangkan panel Dock Panel Tambatkan panel + Dock to Top Tambatkan ke atas + Dock to Bottom Tambatkan ke bawah + Dock Control Bar Tambatkan bilah kontrol Float Tool Group Mengambangkan grup alat Services Layanan Hide VectorCraft Sembunyikan VectorCraft @@ -878,6 +886,8 @@ lighting Light Cahaya Expand Layers Panel Rows Perluas baris panel Lapisan Export CSS… Ekspor CSS… Export Keyboard Shortcuts… Ekspor pintasan papan ketik… + Export Keyboard Shortcuts as Text… Ekspor pintasan papan ketik sebagai teks… + Export Text… Ekspor teks… File Info Dialog Dialog info file Go to Artboard Buka artboard Graphic Style Options… Opsi gaya grafis… @@ -1222,6 +1232,7 @@ lighting Light Cahaya Rotate Tool Alat putar Rotate View Tool Alat putar tampilan Trackpad Gesture to Rotate View Putar tampilan dengan gestur trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Tahan pintasan alat untuk menggunakan alat itu sementara Rotate View Putar tampilan Rounded Rectangle Tool Alat persegi panjang bulat Scale Tool Alat skala @@ -1544,6 +1555,7 @@ digits Malayalam Malayalam Step Backward Mundur satu langkah Step Forward Maju satu langkah Clear History Hapus riwayat + New Document from State Dokumen baru dari status Set History Limit… Atur batas riwayat… Open a document to see its history. Buka dokumen untuk melihat riwayatnya. Abutting Bersinggungan @@ -1887,6 +1899,14 @@ digits Malayalam Malayalam Location Lokasi No images Tidak ada gambar Relink to Folder… Tautkan ulang ke folder… + Each Link Separately Setiap tautan terpisah + Selected Instances Instans yang dipilih + All Instances Semua instans + Missing Links from This Folder Tautan hilang dari folder ini +@msg scope {v} is not each|selected|all|missingInFolder scope {v} bukan each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each dan paths digunakan bersama: paths mencantumkan entri berisi ids dan path, sebagai ganti ids, path, dan folder +@msg paths entry {i} needs path and ids (object ids no other entry has) entri paths {i} memerlukan path dan ids (id objek yang tidak dimiliki entri lain) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder menerima path: tautan hilang lainnya dicari di folder file tersebut Show Missing Tampilkan yang hilang Sort by Status Urutkan berdasarkan status Clear Overrides Hapus penimpaan @@ -2374,6 +2394,8 @@ digits Malayalam Malayalam Show Cross Hairs Tampilkan garis silang Show Video Safe Areas Tampilkan area aman video Video Ruler Pixel Aspect Ratio: Rasio aspek piksel penggaris video: + Fade Region Outside Artboard Redupkan wilayah di luar artboard + Update While Dragging Perbarui saat menyeret Hide Video Rulers Sembunyikan penggaris video Client-side (.html) Sisi klien (.html) Color Reduction: Reduksi warna: @@ -2943,6 +2965,7 @@ theme Appearance Tampilan Redefine Symbol Definisikan ulang simbol Remove Brush Stroke Hapus guratan kuas Replace Symbol Ganti simbol + Reset Transformation Atur ulang transformasi Select All Instances Pilih semua instansi Symbol Sprayer Penyemprot simbol Symbolism Tool Alat simbolisme @@ -2970,6 +2993,9 @@ theme Appearance Tampilan Convert Document Color Mode Konversi mode warna dokumen Gamut Warning Peringatan gamut Load Color Profile… Muat profil warna… + Load Character Styles… Muat gaya karakter… + Load Paragraph Styles… Muat gaya paragraf… + Load All Styles… Muat semua gaya… Plates Pelat Spot Color Warna spot Add to Swatches Tambahkan ke Swatches @@ -3399,6 +3425,8 @@ theme Appearance Tampilan Real-time Drawing and Editing Penggambaran dan pengeditan waktu nyata Render Threads (-1 = Automatic) Utas render (-1 = Otomatis) Reset Preferences Atur ulang preferensi + Search Preferences Cari preferensi + No settings found Tidak ada pengaturan yang ditemukan Scale Corners Skalakan sudut Scale Cursor Proportional to UI Skalakan kursor proporsional dengan UI Scale Strokes & Effects Skalakan guratan & efek @@ -3419,6 +3447,7 @@ theme Appearance Tampilan Snapping Tolerance Toleransi kancing Spacing Guides Panduan jarak Invoke Distance Guides Panggil panduan pengukuran jarak + Snap to Last Location Snap ke lokasi terakhir Spanish Spanyol Subdivisions Subdivisi Templates Folder Folder templat @@ -3586,6 +3615,8 @@ axis Both Keduanya Center Tengah Transform in Perspective Transformasi dalam perspektif Grayscale Skala abu-abu + High Color Banyak warna + Low Color Sedikit warna Embed Sematkan Star Bintang Power Saving (integrated) Hemat daya (terintegrasi) @@ -4337,6 +4368,7 @@ imageTrace Outline Kontur @msg nothing to save: the document has no visible art tidak ada yang dapat disimpan: dokumen tidak memiliki karya seni yang terlihat @msg nothing to undo tidak ada yang dapat diurungkan @msg no history to clear tidak ada riwayat yang dapat dihapus +@msg no history state {v} tidak ada status riwayat {v} @msg nothing to warp tidak ada yang dapat diubah bentuknya @msg nothing to wrap tidak ada yang dapat dibungkus @msg object {0} cannot have children objek {0} tidak dapat memiliki anak @@ -4562,6 +4594,7 @@ imageTrace Outline Kontur @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} gambar tidak dapat didekode dan ditampilkan sebagai kotak bersilang @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} gambar tertaut di disk telah berubah: Perbarui Tautan menampilkan versi barunya @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Telah memperbarui {n} berkas tertaut yang berubah di disk +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokumen menetapkan bleed yang dihilangkan oleh file PDF/X ini: aktifkan "Gunakan pengaturan bleed dokumen" @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} diekspor dengan {n} catatan: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} berkas font kini berada di {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} berkas font kini berada di {folder}; {failed} tidak dapat ditambahkan: {reason} @@ -4644,6 +4677,7 @@ imageTrace Outline Kontur @msg No linked files to look for Tidak ada berkas tertaut untuk dicari @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` adalah referensi langkah {r}, yang hanya diselesaikan oleh `run` dan `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` harus berupa array id objek, bukan {v} +@msg `colors` must be an array of colors, not {v} `colors` harus berupa array warna, bukan {v} @msg `corners` must be an array of anchor indices, not {v} `corners` harus berupa array indeks jangkar, bukan {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: hasil langkah {i} tidak memiliki nilai seperti itu (tulis $$ untuk $ literal) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: tidak ada langkah {i} sebelum langkah ini (langkah dihitung mulai dari 0; tulis $$ untuk $ literal) @@ -5687,6 +5721,17 @@ axis Y Axis Sumbu Y The plug-in is computing… Plug-in sedang menghitung… Inspect Plug-in Periksa plug-in Cancel Plug-in Run Batalkan jalannya plug-in +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instal “{name}”, yang meminta izin berikut? + ID: {id} ID: {id} + Version: {version} Versi: {version} + 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 + Keep data of its own with the document Menyimpan datanya sendiri bersama dokumen + Change how the objects its effect is applied to are drawn Mengubah cara objek yang diberi efeknya digambar + Write the document as a file in its own format Menulis dokumen sebagai file dalam formatnya sendiri + Show its progress, see Cancel and write log lines Menampilkan kemajuannya, mengenali pembatalan, dan menulis baris log # Actions panel: action sets (#3712). New Set… Set baru… New Set Set baru @@ -5695,6 +5740,19 @@ axis Y Axis Sumbu Y Action Options… Opsi tindakan… Action Options Opsi tindakan Playback Options… Opsi pemutaran… + Insert Stop… Sisipkan henti… + Insert Stop Sisipkan henti + Record Stop Rekam henti + Allow Continue Izinkan lanjutkan + Continue Lanjutkan + Continue Action Lanjutkan tindakan + Delete Action Step Hapus langkah tindakan + Press Play Current Selection in the Actions panel to go on. Untuk melanjutkan, tekan Putar seleksi saat ini di panel Tindakan. + Stop: {message} Henti: {message} +@msg no action is waiting at a stop tidak ada tindakan yang menunggu di titik henti +@msg message must be text message harus berupa teks +@msg allowContinue must be true or false allowContinue harus true atau false +@msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti Playback Options Opsi pemutaran Accelerated Dipercepat Step by Step Langkah demi langkah @@ -6098,6 +6156,7 @@ breakText Glyphs Glif Gap 5 Jarak 5 @msg {_1}: they came in as the page's art {_1}: diimpor sebagai gambar halaman +@msg warped text objects that came in as drawn art, not as editable type: {_1} objek teks dengan Warp yang diimpor sebagai gambar, bukan sebagai teks yang dapat diedit: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objek yang tergambar berbeda dari halaman file itu sendiri diimpor sebagai gambar halaman: {_1} @msg You must choose a side pattern. Anda harus memilih pola sisi. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 723fa4ab0..b19e01f96 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -84,6 +84,11 @@ 3 Color Job… Lavoro a 3 colori… 30° Angles Angoli di 30° 3D and Materials 3D e materiali + Plane Piano + Extrude Estrusione + Revolve Rivoluzione + Inflate Gonfia + Select objects, then pick an object type to give them a 3D effect. Seleziona gli oggetti, poi scegli un tipo di oggetto per dar loro un effetto 3D. 3D Ribbon Nastro 3D 45° Angles Angoli di 45° 60° Angles Angoli di 60° @@ -284,6 +289,8 @@ font-technology Variable Variabili Show Cross Hairs Mostra mirino Show Video Safe Areas Mostra aree sicure video Video Ruler Pixel Aspect Ratio: Proporzioni pixel dei righelli video: + Fade Region Outside Artboard Attenua area esterna alla tavola da disegno + Update While Dragging Aggiorna durante il trascinamento Hide Video Rulers Nascondi righelli video Background Contents Contenuto sfondo Background Contents: Contenuto sfondo: @@ -1090,6 +1097,8 @@ font-technology Variable Variabili Export for Screens Settings Impostazioni Esporta per schermi Export for Screens… Esporta per schermi… Export Keyboard Shortcuts… Esporta scelte rapide da tastiera… + Export Keyboard Shortcuts as Text… Esporta scelte rapide da tastiera come testo… + Export Text… Esporta testo… Export Option Opzione di esportazione Export PDF Esporta PDF Export PDF Presets Esporta preset PDF @@ -1388,6 +1397,8 @@ file-filter Templates Modelli Grays Grigi Grays and Neutrals Grigi e neutri Grayscale Scala di grigi + High Color Molti colori + Low Color Pochi colori Grayscale images Immagini in scala di grigi Greek & Cyrillic Greco e cirillico Green Verde @@ -1461,6 +1472,7 @@ file-filter Templates Modelli Step Backward Passo indietro Step Forward Passo avanti Clear History Cancella cronologia + New Document from State Nuovo documento dallo stato Set History Limit… Imposta limite cronologia… History States Stati cronologia # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1855,6 +1867,9 @@ file-filter Templates Modelli Live Paint Selection Tool Strumento Selezione pittura dinamica Live Shape Properties Proprietà forma dinamica Load Color Profile… Carica profilo colore… + Load Character Styles… Carica stili carattere… + Load Paragraph Styles… Carica stili paragrafo… + Load All Styles… Carica tutti gli stili… Remove Color Profile… Rimuovi profilo colore… Load Image into Clipboard Carica immagine negli appunti Load Metafile into Clipboard Carica metafile negli appunti @@ -2726,6 +2741,14 @@ digits Malayalam Malayalam Relink Ricollega Relink Linked Files to the Document Ricollega i file collegati al documento Relink to Folder… Ricollega a cartella… + Each Link Separately Ogni collegamento separatamente + Selected Instances Istanze selezionate + All Instances Tutte le istanze + Missing Links from This Folder Collegamenti mancanti da questa cartella +@msg scope {v} is not each|selected|all|missingInFolder scope {v} non è each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each e paths vanno insieme: paths elenca voci con ids e path, al posto di ids, path e folder +@msg paths entry {i} needs path and ids (object ids no other entry has) la voce {i} di paths richiede path e ids (ID di oggetti che nessun'altra voce ha) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder accetta path: nella cartella di quel file si cercano gli altri collegamenti mancanti Relink… Ricollega… Find Linked Files Trova file collegati Find Linked Files in Folder… Trova file collegati in una cartella… @@ -2770,6 +2793,7 @@ digits Malayalam Malayalam 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 Replace Symbol Sostituisci simbolo + Reset Transformation Reimposta trasformazione Replace With Font Sostituisci con font Replace With Font From Sostituisci con font da System Sistema @@ -2786,6 +2810,8 @@ digits Malayalam Malayalam Reset OpenType Features Reimposta funzioni OpenType Reset Panel Reimposta pannello Reset Preferences Reimposta preferenze + Search Preferences Cerca nelle preferenze + No settings found Nessuna impostazione trovata Reset Profiles Reimposta profili Rotate View Ruota vista Reset Rotate View Reimposta rotazione vista @@ -2841,6 +2867,7 @@ digits Malayalam Malayalam Rotate Tool Strumento Ruota Rotate View Tool Strumento Ruota vista Trackpad Gesture to Rotate View Ruota vista con un gesto sul trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Tieni premuta la scorciatoia di uno strumento per usarlo temporaneamente Rotate… Ruota… Rotation Rotazione Roughen… Irruvidisci… @@ -3282,6 +3309,7 @@ digits Malayalam Malayalam Spaces Spazi Spacing Guides Guide di spaziatura Invoke Distance Guides Richiama guide di distanza + Snap to Last Location Aggancia all'ultima posizione Spacing: Spaziatura: Spanish Spagnolo Specified Distance Distanza specificata @@ -3971,6 +3999,9 @@ axis Both Entrambi Put back in the toolbar Riporta nella barra degli strumenti Float Panel Rendi mobile pannello Dock Panel Ancora pannello + Dock to Top Ancora in alto + Dock to Bottom Ancora in basso + Dock Control Bar Ancora barra di controllo Drag to float the toolbar Trascina per rendere mobile la barra degli strumenti Drag to the window's left edge to dock Trascina sul bordo sinistro della finestra per ancorare Float Tool Group Rendi mobile gruppo strumenti @@ -4418,6 +4449,7 @@ axis Both Entrambi @msg nothing to save: the document has no visible art niente da salvare: il documento non ha grafica visibile @msg nothing to undo niente da annullare @msg no history to clear nessuna cronologia da cancellare +@msg no history state {v} nessuno stato della cronologia {v} @msg nothing to warp niente da alterare @msg nothing to wrap niente da avvolgere @msg object {0} cannot have children l'oggetto {0} non può avere elementi figli @@ -4666,6 +4698,7 @@ axis Both Entrambi @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} immagine non decodificabile: viene mostrata come riquadro barrato da una croce|{n} immagini non decodificabili: vengono mostrate come riquadri barrati da una croce @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} immagine collegata modificata su disco: Aggiorna collegamenti mostra la nuova versione|{n} immagini collegate modificate su disco: Aggiorna collegamenti mostra le nuove versioni @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Aggiornato {n} file collegato modificato su disco|Aggiornati {n} file collegati modificati su disco +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings il documento imposta un'abbondanza che questo file PDF/X omette: attiva «Usa impostazioni abbondanza del documento» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Esportato {what} con {n} nota: {first}|Esportato {what} con {n} note: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} file di font ora in {folder}|{n} file di font ora in {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} file di font ora in {folder}; impossibile aggiungerne {failed}: {reason}|{n} file di font ora in {folder}; impossibile aggiungerne {failed}: {reason} @@ -4778,6 +4811,7 @@ axis Both Entrambi Perspective Distort Distorsione prospettica @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` è il riferimento al passaggio {r}, che solo `run` e `command_batch` risolvono @msg `ids` must be an array of object ids, not {v} `ids` deve essere un elenco di ID oggetto, non {v} +@msg `colors` must be an array of colors, not {v} `colors` deve essere un elenco di colori, non {v} @msg `corners` must be an array of anchor indices, not {v} `corners` deve essere un elenco di indici di punti di ancoraggio, non {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale) @@ -5404,6 +5438,33 @@ axis Y Axis Asse Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? + ID: {id} ID: {id} + Version: {version} Versione: {version} + File SHA-256: {hash} SHA-256 del file: {hash} + Read the objects it is given Leggere gli oggetti che riceve + Change, add and delete objects in the document Modificare, aggiungere ed eliminare oggetti nel documento + Keep data of its own with the document Conservare dati propri con il documento + Change how the objects its effect is applied to are drawn Cambiare il modo in cui vengono disegnati gli oggetti a cui è applicato il suo effetto + Write the document as a file in its own format Scrivere il documento come file in un formato proprio + Show its progress, see Cancel and write log lines Mostrare il proprio avanzamento, rilevare l’annullamento e scrivere righe di registro +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `progress` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} il modulo importa `env.{name}` con un tipo diverso da quello dell’host: quello dell’host è {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (livello API dei plug-in 2), e nessun WASI, file o rete +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` è {_1}; questo host supporta i livelli API dei plug-in da 1 a {API_LEVEL} +@msg `grant` must be a list of permission names `grant` deve essere un elenco di nomi di autorizzazioni +@msg `grant` names an unknown permission `{shown}` `grant` indica un’autorizzazione sconosciuta `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` un plug-in di tipo `{_1}` richiede l’autorizzazione `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` al massimo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorizzazione `{shown}` non si applica a un plug-in di tipo `{_1}` +@msg permission `{shown}` is listed twice l’autorizzazione `{shown}` è elencata due volte +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede +@msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla +@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 # Actions panel: action sets (#3712). New Set… Nuovo set… New Set Nuovo set @@ -5412,6 +5473,19 @@ axis Y Axis Asse Y Action Options… Opzioni azione… Action Options Opzioni azione Playback Options… Opzioni di riproduzione… + Insert Stop… Inserisci interruzione… + Insert Stop Inserisci interruzione + Record Stop Registra interruzione + Allow Continue Consenti di continuare + Continue Continua + Continue Action Continua azione + Delete Action Step Elimina passaggio dell’azione + Press Play Current Selection in the Actions panel to go on. Per continuare, premi Esegui selezione corrente nel pannello Azioni. + Stop: {message} Interruzione: {message} +@msg no action is waiting at a stop nessuna azione è in attesa a un'interruzione +@msg message must be text message deve essere testo +@msg allowContinue must be true or false allowContinue deve essere true o false +@msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione Playback Options Opzioni di riproduzione Accelerated Accelerata Step by Step Passo per passo @@ -5854,6 +5928,7 @@ breakText Glyphs Glifi Gap 5 Spazio 5 @msg {_1}: they came in as the page's art {_1}: sono stati importati come elementi grafici della pagina +@msg warped text objects that came in as drawn art, not as editable type: {_1} oggetti di testo alterati importati come elementi grafici, non come testo modificabile: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} gli oggetti disegnati in modo diverso dalla pagina del file stesso sono stati importati come elementi grafici della pagina: {_1} @msg You must choose a side pattern. Devi scegliere un motivo laterale. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8bb101269..77d50e0a8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -487,6 +487,11 @@ digits Malayalam マラヤーラム数字 Document Raster Effects Settings… ドキュメントのラスタライズ効果設定… Vector Effects ベクター効果 3D and Materials 3D とマテリアル + Plane 平面 + Extrude 押し出し + Revolve 回転体 + Inflate 膨張 + Select objects, then pick an object type to give them a 3D effect. オブジェクトを選択してから、オブジェクトの種類を選んで 3D 効果を適用してください。 Extrude & Bevel… 押し出しとベベル… Revolve… 回転体… Inflate… 膨張… @@ -1029,6 +1034,8 @@ digits Malayalam マラヤーラム数字 Show Cross Hairs 十字線を表示 Show Video Safe Areas ビデオセーフエリアを表示 Video Ruler Pixel Aspect Ratio: ビデオ定規のピクセル縦横比: + Fade Region Outside Artboard アートボードの外側を暗く表示 + Update While Dragging ドラッグ中に更新 Hide Video Rulers ビデオ定規を隠す Background Contents 背景のカラー Background Contents: 背景のカラー: @@ -1581,6 +1588,8 @@ digits Malayalam マラヤーラム数字 Export Document ドキュメントを書き出し Export EPS EPS を書き出し Export Keyboard Shortcuts… キーボードショートカットを書き出し… + Export Keyboard Shortcuts as Text… キーボードショートカットをテキストで書き出し… + Export Text… テキストを書き出し… Export Option 書き出しオプション Export PDF PDF を書き出し Export PDF Presets PDF プリセットを書き出し @@ -1727,6 +1736,8 @@ digits Malayalam マラヤーラム数字 Graphics: {adapter} グラフィックス: {adapter} Grays グレー Grayscale グレースケール + High Color 多色 + Low Color 少色 Flat Logo フラットロゴ Grayscale images グレースケールイメージ Green グリーン @@ -1772,6 +1783,7 @@ digits Malayalam マラヤーラム数字 Step Backward 一段階戻る Step Forward 一段階進む Clear History ヒストリーを消去 + New Document from State 状態から新規ドキュメント Set History Limit… ヒストリーの上限を設定… History States ヒストリー数 # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2081,6 +2093,9 @@ digits Malayalam マラヤーラム数字 Live Paint Selection Tool ライブペイント選択ツール Live Shape Properties ライブシェイプのプロパティ Load Color Profile… カラープロファイルを読み込む… + Load Character Styles… 文字スタイルを読み込み… + Load Paragraph Styles… 段落スタイルを読み込み… + Load All Styles… すべてのスタイルを読み込み… Remove Color Profile… カラープロファイルを削除… Load Image into Clipboard 画像をクリップボードに読み込む Load Metafile into Clipboard メタファイルをクリップボードに読み込む @@ -2715,6 +2730,14 @@ mark-style Japanese 日本式 Release to Layers (Sequence) レイヤーに分配 (シーケンス) Relink 再リンク Relink to Folder… フォルダーに再リンク… + Each Link Separately リンクごとに個別 + Selected Instances 選択したインスタンス + All Instances すべてのインスタンス + Missing Links from This Folder このフォルダーから見つからないリンク +@msg scope {v} is not each|selected|all|missingInFolder scope {v} は each|selected|all|missingInFolder のいずれでもありません +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each と paths は一緒に指定します: paths には ids、path、folder の代わりに、ids と path を持つエントリを並べます +@msg paths entry {i} needs path and ids (object ids no other entry has) paths のエントリ {i} には path と ids (ほかのエントリにないオブジェクト ID) が必要です +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder は path を取ります: そのファイルのフォルダーで、ほかの見つからないリンクを探します Relink… 再リンク… Find Linked Files リンクファイルを検索 Find Linked Files in Folder… フォルダー内のリンクファイルを検索… @@ -2750,6 +2773,7 @@ mark-style Japanese 日本式 Replace: 置換: Replace Font フォントを置換 Replace Symbol シンボルを置き換え + Reset Transformation 変形をリセット Replace With Font 置換するフォント Replace With Font From 置換するフォントの一覧 System システム @@ -2764,6 +2788,8 @@ mark-style Japanese 日本式 Reset OpenType Features OpenType 機能をリセット Reset Panel パネルをリセット Reset Preferences 環境設定をリセット + Search Preferences 環境設定を検索 + No settings found 設定が見つかりません Reset Profiles プロファイルをリセット Reset the rows to the artwork's colors 行をアートワークのカラーにリセット Reset to Default 初期設定に戻す @@ -2798,6 +2824,7 @@ mark-style Japanese 日本式 Rotate Tool 回転ツール Rotate View Tool 回転ビューツール Trackpad Gesture to Rotate View トラックパッドのジェスチャーでビューを回転 + Hold a Tool Shortcut to Use the Tool Temporarily ツールのショートカットを押している間だけツールを使用 Rotation 回転 Round Cap 丸型線端 Round Join ラウンド結合 @@ -3064,6 +3091,7 @@ mark-style Japanese 日本式 Spaces スペース Spacing Guides 間隔ガイド Invoke Distance Guides 距離ガイドを呼び出す + Snap to Last Location 最後の位置にスナップ Spacing: 間隔: Spanish スペイン語 Specify: 指定: @@ -3913,6 +3941,9 @@ axis Both 両方 Put back in the toolbar ツールバーに戻す Float Panel パネルをフローティング Dock Panel パネルをドッキング + Dock to Top 上部にドッキング + Dock to Bottom 下部にドッキング + Dock Control Bar コントロールバーをドッキング Drag to float the toolbar ドラッグしてツールバーをフローティング Drag to the window's left edge to dock ウィンドウの左端までドラッグしてドッキング Float Tool Group ツールグループをフローティング @@ -4166,6 +4197,7 @@ swatch Bright Violet ビビッドバイオレット @msg nothing to redo やり直す操作がありません @msg nothing to undo 取り消す操作がありません @msg no history to clear 消去するヒストリーがありません +@msg no history state {v} ヒストリーの状態 {v} はありません @msg Expand Appearance: the selection has a basic appearance アピアランスを分割: 選択範囲は基本アピアランスです @msg select (or target) objects whose appearance isn't basic 基本アピアランスではないオブジェクトを選択するか、ターゲットに指定してください @msg {C}: no effect at index {from} {C}: インデックス {from} に効果がありません @@ -4737,6 +4769,7 @@ swatch Bright Violet ビビッドバイオレット @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} 個の画像をデコードできませんでした: バツ印の付いた枠で表示しています @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk ディスク上で変更された {n} 個のリンクファイルを更新しました +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings ドキュメントに設定された裁ち落としがこの PDF/X ファイルには含まれません: 「ドキュメントの裁ち落とし設定を使用」をオンにしてください @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} を書き出しましたが、注意事項が {n} 件あります: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} 個のフォントファイルが {folder} に入りました @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} 個のフォントファイルが {folder} に入りましたが、{failed} 個は追加できませんでした: {reason} @@ -4819,6 +4852,7 @@ swatch Bright Violet ビビッドバイオレット @msg No linked files to look for 検索するリンクファイルがありません @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) +@msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません) @msg `corners` must be an array of anchor indices, not {v} `corners` はアンカーポイントのインデックスの配列にしてください ({v} は使えません) @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください) @@ -5469,6 +5503,33 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません @msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか? + ID: {id} ID: {id} + Version: {version} バージョン: {version} + File SHA-256: {hash} ファイルの SHA-256: {hash} + Read the objects it is given 渡されたオブジェクトの読み取り + Change, add and delete objects in the document ドキュメント内のオブジェクトの変更、追加、削除 + Keep data of its own with the document 独自のデータをドキュメントと一緒に保存 + Change how the objects its effect is applied to are drawn 効果が適用されたオブジェクトの描画方法の変更 + Write the document as a file in its own format ドキュメントを独自形式のファイルとして書き出し + Show its progress, see Cancel and write log lines 進行状況の表示、キャンセルの検知、ログ行の書き込み +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `progress` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください +@msg the module imports `env.{name}` with another type than the host's: it is {signature} モジュールが `env.{name}` をホストと異なる型でインポートしています。ホストの型は {signature} です +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `progress` がある場合だけです +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは `env.vc_progress`、`env.vc_cancelled`、`env.vc_log` (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です +@msg `grant` must be a list of permission names `grant` は権限名のリストにしてください +@msg `grant` names an unknown permission `{shown}` `grant` に不明な権限 `{shown}` が含まれています +@msg a plug-in of kind `{_1}` needs the permission `{_2}` 種類 `{_1}` のプラグインには権限 `{_2}` が必要です +@msg at most {MAX_PERMISSIONS} `permissions` `permissions` は最大 {MAX_PERMISSIONS} 個です +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 権限 `{shown}` は種類 `{_1}` のプラグインには適用されません +@msg permission `{shown}` is listed twice 権限 `{shown}` が 2 回指定されています +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません +@msg unknown permission `{shown}` 不明な権限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした # Actions panel: action sets (#3712). New Set… 新規セット… New Set 新規セット @@ -5477,6 +5538,19 @@ axis Y Axis Y 軸 Action Options… アクションオプション… Action Options アクションオプション Playback Options… 再生オプション… + Insert Stop… 停止を挿入… + Insert Stop 停止を挿入 + Record Stop 停止を記録 + Allow Continue 続行を許可 + Continue 続行 + Continue Action アクションを続行 + Delete Action Step アクションステップを削除 + Press Play Current Selection in the Actions panel to go on. 続行するには、アクションパネルの「選択項目を再生」を押します。 + Stop: {message} 停止: {message} +@msg no action is waiting at a stop 停止で待機中のアクションはありません +@msg message must be text message にはテキストを指定してください +@msg allowContinue must be true or false allowContinue には true または false を指定してください +@msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません Playback Options 再生オプション Accelerated 高速 Step by Step ステップ実行 @@ -5919,6 +5993,7 @@ breakText Glyphs 字形 Gap 5 間隔 5 @msg {_1}: they came in as the page's art {_1}: ページのアートとして読み込みました +@msg warped text objects that came in as drawn art, not as editable type: {_1} 編集可能なテキストではなくアートとして読み込んだワープのテキストオブジェクト: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} ファイル自身のページと描画が異なるオブジェクトをページのアートとして読み込みました: {_1} @msg You must choose a side pattern. 側面パターンを選択してください。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 343b415a4..c710ee3c5 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -380,6 +380,11 @@ Document Raster Effects Settings… დოკუმენტის რასტრული ეფექტების პარამეტრები… Vector Effects ვექტორული ეფექტები 3D and Materials 3D და მასალები + Plane სიბრტყე + Extrude ამოწევა + Revolve ბრუნვა + Inflate გაბერვა + Select objects, then pick an object type to give them a 3D effect. აირჩიეთ ობიექტები, შემდეგ აირჩიეთ ობიექტის ტიპი, რომ მათ 3D ეფექტი მიეცეთ. Extrude & Bevel… ამოწევა და წაკვეთა… Revolve… ბრუნვა… Inflate… გაბერვა… @@ -703,6 +708,9 @@ Put back in the toolbar ხელსაწყოთა ზოლში დაბრუნება Float Panel მცურავი პანელი Dock Panel პანელის მიმაგრება + Dock to Top ზემოთ მიმაგრება + Dock to Bottom ქვემოთ მიმაგრება + Dock Control Bar მართვის ზოლის მიმაგრება Float Tool Group მცურავი ხელსაწყოთა ჯგუფი Services სერვისები Hide VectorCraft VectorCraft-ის დამალვა @@ -831,6 +839,8 @@ lighting Light სინათლე Expand Layers Panel Rows შრეების პანელის სტრიქონების გაშლა Export CSS… CSS-ის ექსპორტირება… Export Keyboard Shortcuts… კლავიატურის მალსახმობების ექსპორტირება… + Export Keyboard Shortcuts as Text… კლავიატურის მალსახმობების ტექსტად ექსპორტირება… + Export Text… ტექსტის ექსპორტი… File Info Dialog ფაილის ინფორმაციის დიალოგი Go to Artboard სამუშაო დაფაზე გადასვლა Graphic Style Options… გრაფიკული სტილის პარამეტრები… @@ -1796,6 +1806,14 @@ digits Malayalam მალაიალამური Location მდებარეობა No images გამოსახულებები არ არის Relink to Folder… საქაღალდეში ხელახლა დაკავშირება… + Each Link Separately თითოეული ბმული ცალ-ცალკე + Selected Instances არჩეული ეგზემპლარები + All Instances ყველა ეგზემპლარი + Missing Links from This Folder დაკარგული ბმულები ამ საქაღალდიდან +@msg scope {v} is not each|selected|all|missingInFolder scope {v} არ არის each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each და paths ერთად გამოიყენება: paths ჩამოთვლის ჩანაწერებს ids-ით და path-ით, ids-ის, path-ისა და folder-ის ნაცვლად +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-ის ჩანაწერს {i} სჭირდება path და ids (ობიექტის ID-ები, რომლებიც სხვა ჩანაწერს არ აქვს) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder იღებს path-ს: სხვა დაკარგული ბმულები ამ ფაილის საქაღალდეში იძებნება Show Missing დაკარგულების ჩვენება Sort by Status სტატუსის მიხედვით სორტირება Clear Overrides ცვლილებების გასუფთავება @@ -2277,6 +2295,8 @@ digits Malayalam მალაიალამური Show Cross Hairs ჯვარედინი ხაზების ჩვენება Show Video Safe Areas ვიდეოს უსაფრთხო არეების ჩვენება Video Ruler Pixel Aspect Ratio: ვიდეოს სახაზავების პიქსელის თანაფარდობა: + Fade Region Outside Artboard სამუშაო დაფის გარეთ არის დაბნელება + Update While Dragging განახლება გადათრევისას Hide Video Rulers ვიდეოს სახაზავების დამალვა Client-side (.html) კლიენტის მხარე (.html) Color Reduction: ფერების შემცირება: @@ -2763,6 +2783,7 @@ theme Appearance იერსახე Redefine Symbol სიმბოლოს ხელახლა განსაზღვრა Remove Brush Stroke ფუნჯის შტრიხის მოხსნა Replace Symbol სიმბოლოს ჩანაცვლება + Reset Transformation გარდაქმნის ჩამოყრა Select All Instances ყველა ეგზემპლარის მონიშვნა Symbol Sprayer სიმბოლოების სპრეი Symbolism Tool სიმბოლოების ხელსაწყო @@ -2790,6 +2811,9 @@ theme Appearance იერსახე Convert Document Color Mode დოკუმენტის ფერის რეჟიმის გარდაქმნა Gamut Warning ფერთა გამიდან გასვლის გაფრთხილება Load Color Profile… ფერის პროფილის ჩატვირთვა… + Load Character Styles… სიმბოლოს სტილების ჩატვირთვა… + Load Paragraph Styles… აბზაცის სტილების ჩატვირთვა… + Load All Styles… ყველა სტილის ჩატვირთვა… Plates ფირფიტები Spot Color სპოტ-ფერი Add to Swatches ფერის ნიმუშებში დამატება @@ -3217,6 +3241,8 @@ theme Appearance იერსახე Real-time Drawing and Editing რეალურ დროში ხატვა და რედაქტირება Render Threads (-1 = Automatic) ნაკადები (-1 = ავტომატური) Reset Preferences პარამეტრების ჩამოყრა + Search Preferences პარამეტრებში ძიება + No settings found პარამეტრები ვერ მოიძებნა Scale Corners კუთხეების მასშტაბირება Scale Cursor Proportional to UI კურსორის მასშტაბირება ინტერფეისის პროპორციულად Scale Strokes & Effects კონტურებისა და ეფექტების მასშტაბირება @@ -3237,6 +3263,7 @@ theme Appearance იერსახე Snapping Tolerance მიბმის დაშვება Spacing Guides შუალედის დამხმარე ხაზები Invoke Distance Guides მანძილის დამხმარე ხაზების გამოძახება + Snap to Last Location ბოლო მდებარეობაზე მიბმა Spanish ესპანური Subdivisions ქვედანაყოფები Templates Folder შაბლონების საქაღალდე @@ -3382,6 +3409,8 @@ axis Both ორივე Center ცენტრში Transform in Perspective პერსპექტივაში გარდაქმნა Grayscale ნაცრისფერი გრადაციები + High Color ბევრი ფერი + Low Color ცოტა ფერი Embed ჩაშენება Star ვარსკვლავი Power Saving (integrated) ენერგიის დაზოგვა (ინტეგრირებული) @@ -4362,6 +4391,7 @@ font-technology Variable ცვლადი @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes სურათების დეკოდირება ვერ მოხერხდა ({n}): ისინი ჯვრით გადახაზული ჩარჩოებით ჩანს @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions მიბმული გამოსახულებები შეიცვალა დისკზე ({n}): ბმულების განახლება აჩვენებს ახალ ვერსიებს @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk განახლდა დისკზე შეცვლილი დაკავშირებული ფაილები: {n} +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings დოკუმენტი ადგენს ჭრისპირა არეს, რომელსაც ეს PDF/X ფაილი გამოტოვებს: ჩართეთ „დოკუმენტის ჭრისპირა არის პარამეტრების გამოყენება“ @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} ექსპორტირებულია {what}, შენიშვნები: {n}: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} შრიფტის ფაილები ({n}) ახლა აქ არის: {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} შრიფტის ფაილები ({n}) ახლა აქ არის: {folder}; ვერ დაემატა {failed}: {reason} @@ -4438,6 +4468,7 @@ font-technology Variable ცვლადი @msg every numbered name for it is taken in the Fonts folder შრიფტების საქაღალდეში მისთვის ყველა დანომრილი სახელი დაკავებულია @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` არის ნაბიჯის მიმართვა {r}, რომელსაც მხოლოდ `run` და `command_batch` ხსნის @msg `ids` must be an array of object ids, not {v} `ids` უნდა იყოს ობიექტების id-ების მასივი და არა {v} +@msg `colors` must be an array of colors, not {v} `colors` უნდა იყოს ფერების მასივი და არა {v} @msg `corners` must be an array of anchor indices, not {v} `corners` უნდა იყოს საყრდენი წერტილების ინდექსების მასივი და არა {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ნაბიჯ {i}-ის შედეგს ასეთი მნიშვნელობა არ აქვს (ლიტერალური $-ისთვის დაწერეთ $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: ამის წინ ნაბიჯი {i} არ არის (ნაბიჯები 0-დან ითვლება; ლიტერალური $-ისთვის დაწერეთ $$) @@ -5500,6 +5531,7 @@ imageTrace Outline კონტური The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. შენახვის პანელში სახელი იყო „{typed}“; VectorCraft-მა ფაილის ტიპის გაფართოება დაამატა. ფაილის ჩანაცვლება მის შიგთავსს გადააწერს. Thread Text ტექსტის დაკავშირება Trackpad Gesture to Rotate View ხედის შემობრუნება ტრეკპადის ჟესტით + Hold a Tool Shortcut to Use the Tool Temporarily ხელსაწყოს დროებით გამოყენება მისი მალსახმობის დაჭერით Typing აკრეფა Uniformly Scaled თანაბრად მასშტაბირებული Unlock Variables ცვლადების განბლოკვა @@ -5561,6 +5593,7 @@ imageTrace Outline კონტური @plural {n} more export waiting|{n} more exports waiting რიგში მომლოდინე სხვა ექსპორტირება: {n} @msg Export cancelled ექსპორტირება გაუქმდა Clear History ისტორიის გასუფთავება + New Document from State ახალი დოკუმენტი მდგომარეობიდან Continue in Background ფონურ რეჟიმში გაგრძელება Artboard {n} of {total} სამუშაო დაფა {n} / {total} File {n} of {total} ფაილი {n} / {total} @@ -5579,6 +5612,7 @@ imageTrace Outline კონტური @msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page მისი რედაქტირების მონაცემები {w} × {h} pt გვერდს ეკუთვნის და არა ფაილის {pw} × {ph} pt გვერდს @msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} მისი რედაქტირების მონაცემები {w} × {h} pt გვერდს ეკუთვნის: ეს ფაილის {pw} × {ph} pt გვერდია მასშტაბით {scale} @msg no history to clear გასასუფთავებელი ისტორია არ არის +@msg no history state {v} ისტორიის მდგომარეობა {v} არ არსებობს @msg {_1} files are written by file.saveAsTemplate {_1} ფაილებს წერს file.saveAsTemplate @msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 წერს ძველი ფორმატის .ai ფაილებს და არა .ait შაბლონებს @msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables ფაილის ცვლადების ყველა კავშირი არ იქნა წაკითხული (გამოტოვებული კავშირები: {_1}): ეს ობიექტები არ არის დაკავშირებული თავიანთ ცვლადებთან @@ -5711,6 +5745,33 @@ axis Y Axis Y ღერძი @msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy` @msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს? + ID: {id} ID: {id} + Version: {version} ვერსია: {version} + File SHA-256: {hash} ფაილის SHA-256: {hash} + Read the objects it is given მიღებული ობიექტების წაკითხვა + Change, add and delete objects in the document დოკუმენტში ობიექტების შეცვლა, დამატება და წაშლა + Keep data of its own with the document საკუთარი მონაცემების დოკუმენტთან ერთად შენახვა + Change how the objects its effect is applied to are drawn იმის შეცვლა, თუ როგორ იხატება ობიექტები, რომლებზეც მისი ეფექტია გამოყენებული + Write the document as a file in its own format დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში + Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `progress` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `progress` შეუძლია +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ `env.vc_progress`, `env.vc_cancelled` და `env.vc_log` (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს +@msg `grant` must be a list of permission names `grant` ნებართვების სახელების სია უნდა იყოს +@msg `grant` names an unknown permission `{shown}` `grant` უცნობ ნებართვას ასახელებს: `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` სახის პლაგინს სჭირდება ნებართვა `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` მაქსიმუმ {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` ნებართვა `{shown}` არ ეხება `{_1}` სახის პლაგინს +@msg permission `{shown}` is listed twice ნებართვა `{shown}` ორჯერაა ჩამოთვლილი +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს +@msg unknown permission `{shown}` უცნობი ნებართვა `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა # Actions panel: action sets (#3712). New Set… ახალი ნაკრები… New Set ახალი ნაკრები @@ -5719,6 +5780,19 @@ axis Y Axis Y ღერძი Action Options… მოქმედების პარამეტრები… Action Options მოქმედების პარამეტრები Playback Options… შესრულების პარამეტრები… + Insert Stop… გაჩერების ჩასმა… + Insert Stop გაჩერების ჩასმა + Record Stop გაჩერების ჩაწერა + Allow Continue გაგრძელების დაშვება + Continue გაგრძელება + Continue Action მოქმედების გაგრძელება + Delete Action Step მოქმედების ნაბიჯის წაშლა + Press Play Current Selection in the Actions panel to go on. გასაგრძელებლად მოქმედებების პანელზე დააჭირეთ „მიმდინარე მონიშნულის შესრულებას“. + Stop: {message} გაჩერება: {message} +@msg no action is waiting at a stop გაჩერებაზე არცერთი მოქმედება არ ელოდება +@msg message must be text message უნდა იყოს ტექსტი +@msg allowContinue must be true or false allowContinue უნდა იყოს true ან false +@msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება Playback Options შესრულების პარამეტრები Accelerated დაჩქარებული Step by Step ნაბიჯ-ნაბიჯ @@ -6231,6 +6305,7 @@ breakText Glyphs გლიფები Gap 5 შუალედი 5 @msg {_1}: they came in as the page's art {_1}: იმპორტირებულია გვერდის გამოსახულების სახით +@msg warped text objects that came in as drawn art, not as editable type: {_1} გაღუნული ტექსტური ობიექტები, რომლებიც იმპორტირებულია გამოსახულების და არა რედაქტირებადი ტექსტის სახით: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} ობიექტები, რომლებიც ფაილის საკუთარი გვერდისგან განსხვავებულად იხატებოდა, იმპორტირებულია გვერდის გამოსახულების სახით: {_1} @msg You must choose a side pattern. აირჩიეთ გვერდითი ნიმუში. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f4cc45b82..c0545a3da 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -480,6 +480,11 @@ digits Malayalam 말라얄람어 Document Raster Effects Settings… 문서 래스터 효과 설정… Vector Effects 벡터 효과 3D and Materials 3D 및 재질 + Plane 평면 + Extrude 돌출 + Revolve 회전체 + Inflate 부풀리기 + Select objects, then pick an object type to give them a 3D effect. 개체를 선택한 다음 개체 유형을 골라 3D 효과를 적용하십시오. Extrude & Bevel… 돌출 및 경사… Revolve… 회전체… Inflate… 부풀리기… @@ -1019,6 +1024,8 @@ digits Malayalam 말라얄람어 Show Cross Hairs 십자선 보기 Show Video Safe Areas 비디오 안전 영역 보기 Video Ruler Pixel Aspect Ratio: 비디오 눈금자 픽셀 종횡비: + Fade Region Outside Artboard 아트보드 외부 영역 어둡게 표시 + Update While Dragging 드래그하는 동안 업데이트 Hide Video Rulers 비디오 눈금자 숨기기 Background Contents 배경 내용 Background Contents: 배경 내용: @@ -1571,6 +1578,8 @@ digits Malayalam 말라얄람어 Export Document 문서 내보내기 Export EPS EPS 내보내기 Export Keyboard Shortcuts… 단축키 내보내기… + Export Keyboard Shortcuts as Text… 단축키를 텍스트로 내보내기… + Export Text… 텍스트 내보내기… Export Option 내보내기 옵션 Export PDF PDF 내보내기 Export PDF Presets PDF 프리셋 내보내기 @@ -1717,6 +1726,8 @@ digits Malayalam 말라얄람어 Graphics: {adapter} 그래픽: {adapter} Grays 회색 계열 Grayscale 그레이스케일 + High Color 많은 색상 + Low Color 적은 색상 Flat Logo 단색 로고 Grayscale images 그레이스케일 이미지 Green 녹색 @@ -1762,6 +1773,7 @@ digits Malayalam 말라얄람어 Step Backward 한 단계 뒤로 Step Forward 한 단계 앞으로 Clear History 히스토리 지우기 + New Document from State 상태에서 새 문서 만들기 Set History Limit… 히스토리 한도 설정… History States 히스토리 상태 # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2071,6 +2083,9 @@ digits Malayalam 말라얄람어 Live Paint Selection Tool 라이브 페인트 선택 도구 Live Shape Properties 라이브 모양 속성 Load Color Profile… 색상 프로파일 불러오기… + Load Character Styles… 문자 스타일 불러오기… + Load Paragraph Styles… 단락 스타일 불러오기… + Load All Styles… 모든 스타일 불러오기… Remove Color Profile… 색상 프로파일 제거… Load Image into Clipboard 이미지를 클립보드로 불러오기 Load Metafile into Clipboard 메타파일을 클립보드로 불러오기 @@ -2704,6 +2719,14 @@ mark-style Japanese 일본식 Release to Layers (Sequence) 레이어로 풀기(순차) Relink 다시 연결 Relink to Folder… 폴더로 다시 연결… + Each Link Separately 각 연결 따로 + Selected Instances 선택한 인스턴스 + All Instances 모든 인스턴스 + Missing Links from This Folder 이 폴더에서 누락된 연결 +@msg scope {v} is not each|selected|all|missingInFolder scope {v}은(는) each|selected|all|missingInFolder 중 하나가 아닙니다 +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each와 paths는 함께 사용합니다: paths에는 ids, path, folder 대신 ids와 path가 있는 항목을 나열합니다 +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 항목 {i}에는 path와 ids(다른 항목에 없는 개체 ID)가 필요합니다 +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder는 path를 받습니다: 그 파일의 폴더에서 다른 누락된 연결을 찾습니다 Relink… 다시 연결… Find Linked Files 연결된 파일 찾기 Find Linked Files in Folder… 폴더에서 연결된 파일 찾기… @@ -2739,6 +2762,7 @@ mark-style Japanese 일본식 Replace: 바꾸기: Replace Font 글꼴 바꾸기 Replace Symbol 심볼 바꾸기 + Reset Transformation 변형 재설정 Replace With Font 바꿀 글꼴 Replace With Font From 바꿀 글꼴 목록 System 시스템 @@ -2753,6 +2777,8 @@ mark-style Japanese 일본식 Reset OpenType Features OpenType 기능 재설정 Reset Panel 패널 재설정 Reset Preferences 환경 설정 재설정 + Search Preferences 환경 설정 검색 + No settings found 설정을 찾을 수 없음 Reset Profiles 프로필 재설정 Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정 Reset to Default 기본값으로 재설정 @@ -2787,6 +2813,7 @@ mark-style Japanese 일본식 Rotate Tool 회전 도구 Rotate View Tool 보기 회전 도구 Trackpad Gesture to Rotate View 트랙패드 제스처로 보기 회전 + Hold a Tool Shortcut to Use the Tool Temporarily 도구 단축키를 누르고 있는 동안 도구를 임시로 사용 Rotation 회전 Round Cap 둥근 끝 Round Join 둥근 연결 @@ -3053,6 +3080,7 @@ mark-style Japanese 일본식 Spaces 공백 Spacing Guides 간격 안내선 Invoke Distance Guides 거리 안내선 호출 + Snap to Last Location 마지막 위치에 스냅 Spacing: 간격: Spanish Español Specify: 지정: @@ -3897,6 +3925,9 @@ axis Both 양쪽 Put back in the toolbar 도구 모음으로 되돌리기 Float Panel 패널 분리 Dock Panel 패널 고정 + Dock to Top 위쪽에 고정 + Dock to Bottom 아래쪽에 고정 + Dock Control Bar 컨트롤 막대 고정 Drag to float the toolbar 드래그하여 도구 모음 분리 Drag to the window's left edge to dock 창 왼쪽 가장자리로 드래그하여 고정 Float Tool Group 도구 그룹 분리 @@ -4093,6 +4124,7 @@ theme Appearance 외관 @msg nothing to redo 다시 실행할 항목이 없습니다 @msg nothing to undo 실행 취소할 항목이 없습니다 @msg no history to clear 지울 히스토리가 없습니다 +@msg no history state {v} 히스토리 상태 {v}이(가) 없습니다 @msg Expand Appearance: the selection has a basic appearance 모양새 확장: 선택 항목은 기본 모양새입니다 @msg select (or target) objects whose appearance isn't basic 모양새가 기본이 아닌 개체를 선택(또는 대상 지정)하세요 @msg {C}: no effect at index {from} {C}: 인덱스 {from}에 효과가 없습니다 @@ -4664,6 +4696,7 @@ theme Appearance 외관 @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes 이미지 {n}개를 디코딩할 수 없습니다: X 표시가 된 상자로 표시됩니다 @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions 링크된 이미지 {n}개가 디스크에서 바뀌었습니다: 링크 업데이트를 하면 새 버전이 표시됩니다 @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk 디스크에서 바뀐 링크된 파일 {n}개를 업데이트했습니다 +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings 문서에 설정된 도련이 이 PDF/X 파일에서 빠집니다: "문서 도련 설정 사용"을 켜세요 @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} 참고 {n}건과 함께 {what}을(를) 내보냈습니다: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} 글꼴 파일 {n}개가 이제 {folder}에 있습니다 @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} 글꼴 파일 {n}개가 이제 {folder}에 있으며, {failed}개는 추가할 수 없었습니다: {reason} @@ -4746,6 +4779,7 @@ theme Appearance 외관 @msg No linked files to look for 찾을 링크된 파일이 없습니다 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다 @msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다 +@msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다 @msg `corners` must be an array of anchor indices, not {v} `corners`는 {v}이(가) 아니라 앵커 인덱스 배열이어야 합니다 @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: 단계 {i}의 결과에 해당 값이 없습니다(문자 그대로의 $는 $$로 쓰세요) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: 이 단계 앞에 단계 {i}이(가) 없습니다(단계는 0부터 셉니다. 문자 그대로의 $는 $$로 쓰세요) @@ -5365,6 +5399,33 @@ axis Y Axis Y축 @msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다 @msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요 @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요 +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까? + ID: {id} ID: {id} + Version: {version} 버전: {version} + File SHA-256: {hash} 파일 SHA-256: {hash} + Read the objects it is given 전달받은 개체 읽기 + Change, add and delete objects in the document 문서의 개체 변경, 추가 및 삭제 + Keep data of its own with the document 자체 데이터를 문서와 함께 보관 + Change how the objects its effect is applied to are drawn 효과가 적용된 개체가 그려지는 방식 변경 + Write the document as a file in its own format 문서를 자체 형식의 파일로 쓰기 + Show its progress, see Cancel and write log lines 진행률 표시, 취소 감지 및 로그 줄 쓰기 +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 +@msg the module imports `env.{name}` with another type than the host's: it is {signature} 모듈이 `env.{name}`을(를) 호스트와 다른 형식으로 가져옵니다. 호스트의 형식은 {signature}입니다 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `progress` 권한이 있을 때만 사용할 수 있습니다 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 `env.vc_progress`, `env.vc_cancelled`, `env.vc_log`(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다 +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api`가 {_1}입니다. 이 호스트는 플러그인 API 수준 1부터 {API_LEVEL}까지 지원합니다 +@msg `grant` must be a list of permission names `grant`는 권한 이름 목록이어야 합니다 +@msg `grant` names an unknown permission `{shown}` `grant`에 알 수 없는 권한 `{shown}`이(가) 있습니다 +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 종류의 플러그인에는 `{_2}` 권한이 필요합니다 +@msg at most {MAX_PERMISSIONS} `permissions` `permissions`는 최대 {MAX_PERMISSIONS}개입니다 +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` 권한은 `{_1}` 종류의 플러그인에 적용되지 않습니다 +@msg permission `{shown}` is listed twice `{shown}` 권한이 두 번 나열되어 있습니다 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다 +@msg unknown permission `{shown}` 알 수 없는 권한 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 # Actions panel: action sets (#3712). New Set… 새 세트… New Set 새 세트 @@ -5373,6 +5434,19 @@ axis Y Axis Y축 Action Options… 액션 옵션… Action Options 액션 옵션 Playback Options… 재생 옵션… + Insert Stop… 중지 삽입… + Insert Stop 중지 삽입 + Record Stop 중지 기록 + Allow Continue 계속 허용 + Continue 계속 + Continue Action 액션 계속 + Delete Action Step 액션 단계 삭제 + Press Play Current Selection in the Actions panel to go on. 계속하려면 액션 패널에서 현재 선택 항목 재생을 누르세요. + Stop: {message} 중지: {message} +@msg no action is waiting at a stop 중지에서 대기 중인 액션이 없습니다 +@msg message must be text message의 값은 텍스트여야 합니다 +@msg allowContinue must be true or false allowContinue의 값은 true 또는 false여야 합니다 +@msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다 Playback Options 재생 옵션 Accelerated 가속 Step by Step 단계별 @@ -5815,6 +5889,7 @@ breakText Glyphs 글리프 Gap 5 간격 5 @msg {_1}: they came in as the page's art {_1}: 페이지의 아트로 가져왔습니다 +@msg warped text objects that came in as drawn art, not as editable type: {_1} 편집 가능한 문자가 아닌 아트로 가져온 왜곡된 문자 오브젝트: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} 파일 자체 페이지와 다르게 그려진 오브젝트를 페이지의 아트로 가져왔습니다: {_1} @msg You must choose a side pattern. 측면 패턴을 선택해야 합니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2e8d6f34c..72d4ea21a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -428,6 +428,11 @@ Document Raster Effects Settings… Instellingen voor documentrastereffecten… Vector Effects Vectoreffecten 3D and Materials 3D en materialen + Plane Vlak + Extrude Extruderen + Revolve Draaien + Inflate Opblazen + Select objects, then pick an object type to give them a 3D effect. Selecteer objecten en kies dan een objecttype om ze een 3D-effect te geven. Extrude & Bevel… Extruderen en afschuinen… Revolve… Draaien… Inflate… Opblazen… @@ -767,6 +772,9 @@ Put back in the toolbar Terug in de werkbalk zetten Float Panel Paneel laten zweven Dock Panel Paneel dokken + Dock to Top Bovenaan dokken + Dock to Bottom Onderaan dokken + Dock Control Bar Bedieningsbalk dokken Float Tool Group Gereedschapsgroep laten zweven Services Diensten Hide VectorCraft VectorCraft verbergen @@ -892,6 +900,8 @@ lighting Light Licht Expand Layers Panel Rows Rijen in het Lagen-paneel uitvouwen Export CSS… CSS exporteren… Export Keyboard Shortcuts… Sneltoetsen exporteren… + Export Keyboard Shortcuts as Text… Sneltoetsen als tekst exporteren… + Export Text… Tekst exporteren… File Info Dialog Dialoogvenster bestandsinfo Go to Artboard Ga naar tekengebied Graphic Style Options… Opties voor grafische stijl… @@ -1236,6 +1246,7 @@ lighting Light Licht Rotate Tool Draaien-gereedschap Rotate View Tool Weergave draaien-gereedschap Trackpad Gesture to Rotate View Weergave draaien met trackpadgebaar + Hold a Tool Shortcut to Use the Tool Temporarily Sneltoets van een gereedschap ingedrukt houden om het tijdelijk te gebruiken Rotate View Weergave draaien Rounded Rectangle Tool Afgeronde rechthoek-gereedschap Scale Tool Schalen-gereedschap @@ -1558,6 +1569,7 @@ digits Malayalam Malayalam Step Backward Stap terug Step Forward Stap vooruit Clear History Historie wissen + New Document from State Nieuw document uit status Set History Limit… Historielimiet instellen… Open a document to see its history. Open een document om de historie te zien. Abutting Aaneengesloten @@ -1901,6 +1913,14 @@ digits Malayalam Malayalam Location Locatie No images Geen afbeeldingen Relink to Folder… Opnieuw koppelen aan map… + Each Link Separately Elke koppeling afzonderlijk + Selected Instances Geselecteerde instanties + All Instances Alle instanties + Missing Links from This Folder Ontbrekende koppelingen uit deze map +@msg scope {v} is not each|selected|all|missingInFolder scope {v} is niet each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each en paths horen bij elkaar: paths bevat items met ids en path, in plaats van ids, path en folder +@msg paths entry {i} needs path and ids (object ids no other entry has) item {i} van paths heeft path en ids nodig (object-id's die geen ander item heeft) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder neemt path: in de map van dat bestand wordt naar de andere ontbrekende koppelingen gezocht Show Missing Ontbrekende weergeven Sort by Status Sorteren op status Clear Overrides Onderdrukkingen wissen @@ -2388,6 +2408,8 @@ digits Malayalam Malayalam Show Cross Hairs Dradenkruis tonen Show Video Safe Areas Veilige videogebieden tonen Video Ruler Pixel Aspect Ratio: Pixelverhouding van videolinialen: + Fade Region Outside Artboard Gebied buiten tekenbord dimmen + Update While Dragging Bijwerken tijdens slepen Hide Video Rulers Videolinialen verbergen Client-side (.html) Client-side (.html) Color Reduction: Kleurreductie: @@ -2957,6 +2979,7 @@ theme Appearance Vormgeving Redefine Symbol Symbool opnieuw definiëren Remove Brush Stroke Penseelstreek verwijderen Replace Symbol Symbool vervangen + Reset Transformation Transformatie opnieuw instellen Select All Instances Alle exemplaren selecteren Symbol Sprayer Symboolspuit Symbolism Tool Symbolisatiegereedschap @@ -2984,6 +3007,9 @@ theme Appearance Vormgeving Convert Document Color Mode Kleurmodus van document omzetten Gamut Warning Gamutwaarschuwing Load Color Profile… Kleurprofiel laden… + Load Character Styles… Tekenstijlen laden… + Load Paragraph Styles… Alineastijlen laden… + Load All Styles… Alle stijlen laden… Plates Platen Spot Color Steunkleur Add to Swatches Aan stalen toevoegen @@ -3413,6 +3439,8 @@ theme Appearance Vormgeving Real-time Drawing and Editing Tekenen en bewerken in realtime Render Threads (-1 = Automatic) Renderthreads (-1 = automatisch) Reset Preferences Voorkeuren herstellen + Search Preferences Voorkeuren doorzoeken + No settings found Geen instellingen gevonden Scale Corners Hoeken schalen Scale Cursor Proportional to UI Cursor proportioneel schalen met UI Scale Strokes & Effects Streken & effecten schalen @@ -3433,6 +3461,7 @@ theme Appearance Vormgeving Snapping Tolerance Snapping-tolerantie Spacing Guides Hulplijnen voor tussenruimte Invoke Distance Guides Afstandshulplijnen oproepen + Snap to Last Location Uitlijnen op laatste locatie Spanish Spaans Subdivisions Onderverdelingen Templates Folder Sjablonenmap @@ -3600,6 +3629,8 @@ axis Both Beide Center Midden Transform in Perspective In perspectief transformeren Grayscale Grijswaarden + High Color Veel kleuren + Low Color Weinig kleuren Embed Insluiten Star Ster Power Saving (integrated) Energiebesparend (geïntegreerd) @@ -4349,6 +4380,7 @@ imageTrace Outline Contour @msg nothing to save: the document has no visible art niets om op te slaan: het document heeft geen zichtbare illustratie @msg nothing to undo niets om ongedaan te maken @msg no history to clear geen historie om te wissen +@msg no history state {v} geen historiestatus {v} @msg nothing to warp niets om te buigen @msg nothing to wrap niets om te laten omlopen @msg object {0} cannot have children object {0} kan geen onderliggende objecten hebben @@ -4574,6 +4606,7 @@ imageTrace Outline Contour @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} afbeelding kon niet worden gedecodeerd en wordt als doorgekruist vak weergegeven|{n} afbeeldingen konden niet worden gedecodeerd en worden als doorgekruiste vakken weergegeven @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} gekoppelde afbeelding is gewijzigd op de schijf: Koppelingen bijwerken toont de nieuwe versie|{n} gekoppelde afbeeldingen zijn gewijzigd op de schijf: Koppelingen bijwerken toont de nieuwe versies @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Bijgewerkt: {n} gekoppeld bestand gewijzigd op de schijf|Bijgewerkt: {n} gekoppelde bestanden gewijzigd op de schijf +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings het document stelt een afloop in die dit PDF/X-bestand weglaat: schakel 'Afloopinstellingen van document gebruiken' in @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} geëxporteerd met {n} opmerking: {first}|{what} geëxporteerd met {n} opmerkingen: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} er staat nu {n} lettertypebestand in {folder}|er staan nu {n} lettertypebestanden in {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} er staat nu {n} lettertypebestand in {folder}; {failed} konden niet worden toegevoegd: {reason}|er staan nu {n} lettertypebestanden in {folder}; {failed} konden niet worden toegevoegd: {reason} @@ -4656,6 +4689,7 @@ imageTrace Outline Contour @msg No linked files to look for Geen gekoppelde bestanden om naar te zoeken @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` is de stapverwijzing {r}, die alleen `run` en `command_batch` oplossen @msg `ids` must be an array of object ids, not {v} `ids` moet een array van object-id's zijn, niet {v} +@msg `colors` must be an array of colors, not {v} `colors` moet een array van kleuren zijn, niet {v} @msg `corners` must be an array of anchor indices, not {v} `corners` moet een array van ankerindices zijn, niet {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: het resultaat van stap {i} heeft geen dergelijke waarde (schrijf $$ voor een letterlijke $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: er is geen stap {i} vóór deze stap (stappen tellen vanaf 0; schrijf $$ voor een letterlijke $) @@ -5733,6 +5767,17 @@ axis Y Axis Y-as The plug-in is computing… De plug-in is aan het rekenen… Inspect Plug-in Plug-in inspecteren Cancel Plug-in Run Plug-in-run annuleren +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? „{name}” installeren, dat om deze machtigingen vraagt? + ID: {id} ID: {id} + Version: {version} Versie: {version} + 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 + Keep data of its own with the document Eigen gegevens bij het document bewaren + Change how the objects its effect is applied to are drawn Wijzigen hoe de objecten waarop het effect is toegepast worden getekend + Write the document as a file in its own format Het document als bestand in een eigen indeling schrijven + Show its progress, see Cancel and write log lines De voortgang tonen, een annulering zien en logregels schrijven # Actions panel: action sets (#3712). New Set… Nieuwe set… New Set Nieuwe set @@ -5741,6 +5786,19 @@ axis Y Axis Y-as Action Options… Actieopties… Action Options Actieopties Playback Options… Afspeelopties… + Insert Stop… Stop invoegen… + Insert Stop Stop invoegen + Record Stop Stop opnemen + Allow Continue Doorgaan toestaan + Continue Doorgaan + Continue Action Actie voortzetten + Delete Action Step Actiestap verwijderen + Press Play Current Selection in the Actions panel to go on. Druk in het paneel Acties op Huidige selectie afspelen om door te gaan. + Stop: {message} Stop: {message} +@msg no action is waiting at a stop geen actie wacht bij een stop +@msg message must be text message moet tekst zijn +@msg allowContinue must be true or false allowContinue moet true of false zijn +@msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen Playback Options Afspeelopties Accelerated Versneld Step by Step Stap voor stap @@ -6144,6 +6202,7 @@ breakText Glyphs Glyphs Gap 5 Tussenruimte 5 @msg {_1}: they came in as the page's art {_1}: ze zijn geïmporteerd als pagina-afbeelding +@msg warped text objects that came in as drawn art, not as editable type: {_1} tekstobjecten met Warp die zijn geïmporteerd als afbeelding, niet als bewerkbare tekst: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objecten die anders werden getekend dan de eigen pagina van het bestand, zijn geïmporteerd als pagina-afbeelding: {_1} @msg You must choose a side pattern. Kies een zijpatroon. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c9e1256c3..ff2b9bd6b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -416,6 +416,11 @@ Document Raster Effects Settings… Ustawienia efektów rastrowych dokumentu… Vector Effects Efekty wektorowe 3D and Materials 3D i materiały + Plane Płaszczyzna + Extrude Wyciągnięcie + Revolve Obrót + Inflate Nadmuchanie + Select objects, then pick an object type to give them a 3D effect. Zaznacz obiekty, a następnie wybierz typ obiektu, aby nadać im efekt 3D. Extrude & Bevel… Wyciągnięcie i fazowanie… Revolve… Obrót… Inflate… Nadmuchanie… @@ -765,6 +770,9 @@ Put back in the toolbar Odłóż z powrotem na pasek narzędzi Float Panel Odepnij panel Dock Panel Zadokuj panel + Dock to Top Zadokuj u góry + Dock to Bottom Zadokuj u dołu + Dock Control Bar Zadokuj pasek sterowania Float Tool Group Odepnij grupę narzędzi Services Usługi Hide VectorCraft Ukryj VectorCraft @@ -894,6 +902,8 @@ lighting Light Światło Expand Layers Panel Rows Rozwiń wiersze panelu Warstwy Export CSS… Eksportuj CSS… Export Keyboard Shortcuts… Eksportuj skróty klawiaturowe… + Export Keyboard Shortcuts as Text… Eksportuj skróty klawiaturowe jako tekst… + Export Text… Eksportuj tekst… File Info Dialog Okno informacji o pliku Go to Artboard Przejdź do obszaru kompozycji First Artboard Pierwszy obszar kompozycji @@ -1231,6 +1241,7 @@ lighting Light Światło Rotate Tool Narzędzie Obrót Rotate View Tool Narzędzie Obrót widoku Trackpad Gesture to Rotate View Obracaj widok gestem na gładziku + Hold a Tool Shortcut to Use the Tool Temporarily Używaj narzędzia tymczasowo, przytrzymując jego skrót Rounded Rectangle Tool Narzędzie Zaokrąglony prostokąt Scale Tool Narzędzie Skalowanie Scallop Tool Narzędzie Ząbkowanie @@ -1547,6 +1558,7 @@ digits Malayalam Malajalam Step Backward Krok wstecz Step Forward Krok naprzód Clear History Wyczyść historię + New Document from State Nowy dokument ze stanu Set History Limit… Ustaw limit historii… Open a document to see its history. Otwórz dokument, aby zobaczyć jego historię. # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2001,6 +2013,14 @@ digits Malayalam Malajalam Location Położenie No images Brak obrazów Relink to Folder… Połącz ponownie z folderem… + Each Link Separately Każde łącze osobno + Selected Instances Zaznaczone wystąpienia + All Instances Wszystkie wystąpienia + Missing Links from This Folder Brakujące łącza z tego folderu +@msg scope {v} is not each|selected|all|missingInFolder scope {v} nie jest jednym z each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths występują razem: paths wymienia wpisy z ids i path zamiast ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) wpis {i} w paths wymaga path i ids (identyfikatorów obiektów, których nie ma żaden inny wpis) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder przyjmuje path: w folderze tego pliku szukane są pozostałe brakujące łącza Show Missing Pokaż brakujące Sort by Status Sortuj według stanu Clear Overrides Wyczyść zastąpienia @@ -2500,6 +2520,8 @@ digits Malayalam Malajalam Show Cross Hairs Pokaż krzyż nitkowy Show Video Safe Areas Pokaż bezpieczne obszary wideo Video Ruler Pixel Aspect Ratio: Proporcje piksela linijek wideo: + Fade Region Outside Artboard Przyciemnij obszar poza obszarem kompozycji + Update While Dragging Aktualizuj podczas przeciągania Hide Video Rulers Ukryj linijki wideo Client-side (.html) Po stronie klienta (.html) Color Reduction: Redukcja kolorów: @@ -3078,6 +3100,7 @@ theme Appearance Wygląd Redefine Symbol Zdefiniuj ponownie symbol Remove Brush Stroke Usuń pociągnięcie pędzla Replace Symbol Zastąp symbol + Reset Transformation Resetuj przekształcenie Select All Instances Zaznacz wszystkie wystąpienia Symbol Sprayer Rozpylanie symboli Symbolism Tool Narzędzie symboli @@ -3105,6 +3128,9 @@ theme Appearance Wygląd Convert Document Color Mode Konwertuj tryb koloru dokumentu Gamut Warning Ostrzeżenie gamutu Load Color Profile… Wczytaj profil kolorów… + Load Character Styles… Wczytaj style znakowe… + Load Paragraph Styles… Wczytaj style akapitowe… + Load All Styles… Wczytaj wszystkie style… Remove Color Profile… Usuń profil kolorów… Plates Płyty Spot Color Kolor dodatkowy @@ -3534,6 +3560,8 @@ theme Appearance Wygląd Real-time Drawing and Editing Rysowanie i edycja w czasie rzeczywistym Render Threads (-1 = Automatic) Wątki renderowania (-1 = automatycznie) Reset Preferences Resetuj preferencje + Search Preferences Szukaj w preferencjach + No settings found Nie znaleziono ustawień Scale Corners Skaluj narożniki Scale Cursor Proportional to UI Skaluj kursor proporcjonalnie do interfejsu Scale Strokes & Effects Skaluj obrysy i efekty @@ -3554,6 +3582,7 @@ theme Appearance Wygląd Snapping Tolerance Tolerancja przyciągania Spacing Guides Linie odstępów Invoke Distance Guides Wywołuj linie odległości + Snap to Last Location Przyciągaj do ostatniego położenia Spanish Hiszpański Subdivisions Podpodziały Templates Folder Folder szablonów @@ -3701,6 +3730,8 @@ axis Both Obie Center Środek Transform in Perspective Przekształć w perspektywie Grayscale Skala szarości + High Color Wiele kolorów + Low Color Mało kolorów Embed Osadź Star Gwiazda Power Saving (integrated) Oszczędzanie energii (zintegrowany) @@ -4507,6 +4538,7 @@ imageTrace Outline Kontur @msg nothing to save: the document has no visible art nie ma czego zapisać: dokument nie ma widocznej kompozycji @msg nothing to undo nie ma czego cofnąć @msg no history to clear brak historii do wyczyszczenia +@msg no history state {v} brak stanu historii {v} @msg nothing to warp nie ma czego wypaczyć @msg nothing to wrap nie ma czego oblewać @msg object {0} cannot have children obiekt {0} nie może mieć elementów podrzędnych @@ -4756,6 +4788,7 @@ imageTrace Outline Kontur @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes nie udało się zdekodować {n} obrazu: jest wyświetlany jako przekreślona ramka|nie udało się zdekodować {n} obrazów: są wyświetlane jako przekreślone ramki|nie udało się zdekodować {n} obrazów: są wyświetlane jako przekreślone ramki @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} połączony obraz zmienił się na dysku: Aktualizuj łącza pokaże nową wersję|{n} połączone obrazy zmieniły się na dysku: Aktualizuj łącza pokaże nowe wersje|{n} połączonych obrazów zmieniło się na dysku: Aktualizuj łącza pokaże nowe wersje @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Zaktualizowano {n} połączony plik zmieniony na dysku|Zaktualizowano {n} połączone pliki zmienione na dysku|Zaktualizowano {n} połączonych plików zmienionych na dysku +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokument ustawia spad, który ten plik PDF/X pomija: włącz „Użyj ustawień spadu dokumentu” @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Wyeksportowano {what} z {n} uwagą: {first}|Wyeksportowano {what} z {n} uwagami: {first}|Wyeksportowano {what} z {n} uwagami: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} plik czcionki jest teraz w {folder}|{n} pliki czcionek są teraz w {folder}|{n} plików czcionek jest teraz w {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} plik czcionki jest teraz w {folder}; nie udało się dodać {failed}: {reason}|{n} pliki czcionek są teraz w {folder}; nie udało się dodać {failed}: {reason}|{n} plików czcionek jest teraz w {folder}; nie udało się dodać {failed}: {reason} @@ -4842,6 +4875,7 @@ imageTrace Outline Kontur @msg No linked files to look for Brak połączonych plików do wyszukania @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v} +@msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v} @msg `corners` must be an array of anchor indices, not {v} `corners` musi być tablicą indeksów punktów, nie {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: wynik kroku {i} nie ma takiej wartości (wpisz $$, aby uzyskać znak $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: przed tym krokiem nie ma kroku {i} (kroki liczone od 0; wpisz $$, aby uzyskać znak $) @@ -5641,6 +5675,33 @@ axis Y Axis Oś Y @msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy` @msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? + ID: {id} ID: {id} + Version: {version} Wersja: {version} + File SHA-256: {hash} SHA-256 pliku: {hash} + Read the objects it is given Odczytywać obiekty, które otrzymuje + Change, add and delete objects in the document Zmieniać, dodawać i usuwać obiekty w dokumencie + Keep data of its own with the document Przechowywać własne dane w dokumencie + Change how the objects its effect is applied to are drawn Zmieniać sposób rysowania obiektów, do których zastosowano jej efekt + Write the document as a file in its own format Zapisywać dokument jako plik we własnym formacie + Show its progress, see Cancel and write log lines Pokazywać swój postęp, wykrywać anulowanie i zapisywać wiersze dziennika +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `progress` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} moduł importuje `env.{name}` z innym typem niż typ hosta: typ hosta to {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (poziom API wtyczek 2), bez WASI, plików i sieci +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ma wartość {_1}; ten host obsługuje poziomy API wtyczek od 1 do {API_LEVEL} +@msg `grant` must be a list of permission names `grant` musi być listą nazw uprawnień +@msg `grant` names an unknown permission `{shown}` `grant` wymienia nieznane uprawnienie `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` wtyczka rodzaju `{_1}` potrzebuje uprawnienia `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` najwyżej {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` uprawnienie `{shown}` nie dotyczy wtyczki rodzaju `{_1}` +@msg permission `{shown}` is listed twice uprawnienie `{shown}` występuje dwa razy +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje +@msg unknown permission `{shown}` nieznane uprawnienie `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono +@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 # Actions panel: action sets (#3712). New Set… Nowy zestaw… New Set Nowy zestaw @@ -5649,6 +5710,19 @@ axis Y Axis Oś Y Action Options… Opcje operacji… Action Options Opcje operacji Playback Options… Opcje odtwarzania… + Insert Stop… Wstaw zatrzymanie… + Insert Stop Wstaw zatrzymanie + Record Stop Nagraj zatrzymanie + Allow Continue Zezwalaj na kontynuację + Continue Kontynuuj + Continue Action Kontynuuj operację + Delete Action Step Usuń krok operacji + Press Play Current Selection in the Actions panel to go on. Aby kontynuować, naciśnij Odtwórz bieżące zaznaczenie w panelu Operacje. + Stop: {message} Zatrzymanie: {message} +@msg no action is waiting at a stop żadna operacja nie czeka na zatrzymaniu +@msg message must be text message musi być tekstem +@msg allowContinue must be true or false allowContinue musi mieć wartość true lub false +@msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem Playback Options Opcje odtwarzania Accelerated Przyspieszone Step by Step Krok po kroku @@ -6089,6 +6163,7 @@ breakText Glyphs Glify Gap 5 Odstęp 5 @msg {_1}: they came in as the page's art {_1}: zaimportowano je jako grafikę strony +@msg warped text objects that came in as drawn art, not as editable type: {_1} wypaczone obiekty tekstowe zaimportowane jako grafika, a nie jako edytowalny tekst: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} obiekty rysowane inaczej niż własna strona pliku zaimportowano jako grafikę strony: {_1} @msg You must choose a side pattern. Musisz wybrać wzór boczny. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 062b7d88b..3340597d8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -96,6 +96,11 @@ 30° Angles Ângulos de 30° 3D Ribbon Fita 3D 3D and Materials 3D e Materiais + Plane Plano + Extrude Extrusão + Revolve Revolver + Inflate Inflar + Select objects, then pick an object type to give them a 3D effect. Selecione objetos e escolha um tipo de objeto para dar a eles um efeito 3D. 45° Angles Ângulos de 45° 60° Angles Ângulos de 60° 90° & 45° & 30° Angles Ângulos de 90° & 45° & 30° @@ -270,6 +275,8 @@ Show Cross Hairs Mostrar Linhas Cruzadas Show Video Safe Areas Mostrar Áreas Seguras de Vídeo Video Ruler Pixel Aspect Ratio: Proporção de Pixel das Réguas de Vídeo: + Fade Region Outside Artboard Escurecer Região Fora da Prancheta + Update While Dragging Atualizar ao Arrastar Hide Video Rulers Ocultar Réguas de Vídeo Background Contents Conteúdo do Plano de Fundo Background Contents: Conteúdo do Plano de Fundo: @@ -981,6 +988,8 @@ Export Document Exportar Documento Export EPS Exportar EPS Export Keyboard Shortcuts… Exportar Atalhos de Teclado… + Export Keyboard Shortcuts as Text… Exportar Atalhos de Teclado como Texto… + Export Text… Exportar Texto… Export Option Opção de Exportação Export PDF Exportar PDF Export PDF Presets Exportar Predefinidos de PDF @@ -1203,6 +1212,8 @@ Grays Cinzas Grays and Neutrals Cinzas e Neutros Grayscale Escala de Cinza + High Color Muitas Cores + Low Color Poucas Cores Grayscale images Imagens em escala de cinza Green Verde Grid Grade @@ -1268,6 +1279,7 @@ Step Backward Voltar um Passo Step Forward Avançar um Passo Clear History Limpar Histórico + New Document from State Novo Documento do Estado Set History Limit… Definir Limite do Histórico… History States Estados do Histórico # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1550,6 +1562,9 @@ Live Paint Selection Tool Ferramenta Seleção de Pintura ao Vivo Live Shape Properties Propriedades de Forma ao Vivo Load Color Profile… Carregar Perfil de Cor… + Load Character Styles… Carregar Estilos de Caractere… + Load Paragraph Styles… Carregar Estilos de Parágrafo… + Load All Styles… Carregar Todos os Estilos… Remove Color Profile… Remover Perfil de Cor… Load Image into Clipboard Carregar Imagem na Área de Transferência Load Metafile into Clipboard Carregar Metarquivo na Área de Transferência @@ -2244,6 +2259,14 @@ digits Malayalam Malaiala Release with Perspective Liberar com Perspectiva Relink Revinculcar Relink to Folder… Revinculcar Pasta… + Each Link Separately Cada Vínculo Separadamente + Selected Instances Instâncias Selecionadas + All Instances Todas as Instâncias + Missing Links from This Folder Vínculos Ausentes Desta Pasta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} não é each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each e paths andam juntos: paths lista entradas com ids e path, no lugar de ids, path e folder +@msg paths entry {i} needs path and ids (object ids no other entry has) a entrada {i} de paths precisa de path e ids (ids de objeto que nenhuma outra entrada tenha) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder recebe path: os outros vínculos ausentes são procurados na pasta desse arquivo Relink… Revinculcar… Reload Plug-ins Recarregar Plug-ins Remove Remover @@ -2279,6 +2302,7 @@ digits Malayalam Malaiala Replace Font Substituir Fonte Replace Spine Substituir Coluna Replace Symbol Substituir Símbolo + Reset Transformation Redefinir Transformação Replace With Font Substituir Com Fonte Replace With Font From Substituir Com Fonte De System Sistema @@ -2296,6 +2320,8 @@ digits Malayalam Malaiala Reset OpenType Features Redefinir Recursos OpenType Reset Panel Redefinir Painel Reset Preferences Redefinir Preferências + Search Preferences Pesquisar nas Preferências + No settings found Nenhuma configuração encontrada Reset Profiles Redefinir Perfis Rotate View Girar Visualização Reset Rotate View Redefinir Visualização Girada @@ -2342,6 +2368,7 @@ digits Malayalam Malaiala Rotate Tool Ferramenta Girar Rotate View Tool Ferramenta Girar Visualização Trackpad Gesture to Rotate View Girar Visualização com Gesto do Trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Manter o Atalho de uma Ferramenta Pressionado para Usá-la Temporariamente Rotate… Girar… Rotation Rotação Roughen… Enrugar… @@ -2709,6 +2736,7 @@ digits Malayalam Malaiala Spaces Espaços Spacing Guides Guias de Espaçamento Invoke Distance Guides Invocar Guias de Distância + Snap to Last Location Ajustar ao Último Local Spacing: Espaçamento: Spanish Espanhol Specify: Especificar: @@ -3185,6 +3213,9 @@ imageTrace Outline Contorno Put back in the toolbar Devolver à barra de ferramentas Float Panel Flutuar Painel Dock Panel Encaixar Painel + Dock to Top Encaixar na Parte Superior + Dock to Bottom Encaixar na Parte Inferior + Dock Control Bar Encaixar Barra de Controle Float Tool Group Flutuar Grupo de Ferramentas Services Serviços Hide VectorCraft Ocultar o VectorCraft @@ -3955,6 +3986,7 @@ axis Both Ambos @msg nothing to save: the document has no visible art nada para salvar: o documento não tem arte visível @msg nothing to undo nada para desfazer @msg no history to clear nenhum histórico para limpar +@msg no history state {v} nenhum estado do histórico {v} @msg nothing to warp nada para deformar @msg nothing to wrap nada para envolver @msg object {0} cannot have children o objeto {0} não pode ter filhos @@ -4201,6 +4233,7 @@ axis Both Ambos @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes não foi possível decodificar {n} imagem: ela aparece como uma caixa marcada com um X|não foi possível decodificar {n} imagens: elas aparecem como caixas marcadas com um X @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} imagem vinculada mudou no disco: Atualizar links mostra a nova versão|{n} imagens vinculadas mudaram no disco: Atualizar links mostra as novas versões @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk {n} arquivo vinculado alterado no disco atualizado|{n} arquivos vinculados alterados no disco atualizados +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings o documento define uma sangria que este arquivo PDF/X omite: ative "Usar configurações de sangria do documento" @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} exportado com {n} observação: {first}|{what} exportado com {n} observações: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} arquivo de fonte agora está em {folder}|{n} arquivos de fonte agora estão em {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} arquivo de fonte agora está em {folder}; não foi possível adicionar {failed}: {reason}|{n} arquivos de fonte agora estão em {folder}; não foi possível adicionar {failed}: {reason} @@ -4280,6 +4313,7 @@ axis Both Ambos Artboard Prancheta @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` é a referência de etapa {r}, que somente `run` e `command_batch` resolvem @msg `ids` must be an array of object ids, not {v} `ids` deve ser uma matriz de ids de objeto, não {v} +@msg `colors` must be an array of colors, not {v} `colors` deve ser uma matriz de cores, não {v} @msg `corners` must be an array of anchor indices, not {v} `corners` deve ser uma matriz de índices de âncora, não {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: o resultado da etapa {i} não tem esse valor (escreva $$ para um $ literal) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: não há etapa {i} antes desta (as etapas contam a partir de 0; escreva $$ para um $ literal) @@ -5703,6 +5737,33 @@ axis Y Axis Eixo Y @msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? + ID: {id} ID: {id} + Version: {version} Versão: {version} + File SHA-256: {hash} SHA-256 do arquivo: {hash} + Read the objects it is given Ler os objetos que recebe + Change, add and delete objects in the document Alterar, adicionar e excluir objetos no documento + Keep data of its own with the document Guardar dados próprios com o documento + Change how the objects its effect is applied to are drawn Alterar como são desenhados os objetos aos quais seu efeito é aplicado + Write the document as a file in its own format Gravar o documento como um arquivo em formato próprio + Show its progress, see Cancel and write log lines Mostrar seu progresso, detectar o cancelamento e gravar linhas de log +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `progress` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} o módulo importa `env.{name}` com um tipo diferente do tipo do host: o do host é {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` é {_1}; este host aceita os níveis de API de plug-in de 1 a {API_LEVEL} +@msg `grant` must be a list of permission names `grant` deve ser uma lista de nomes de permissões +@msg `grant` names an unknown permission `{shown}` `grant` cita uma permissão desconhecida `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` um plug-in do tipo `{_1}` precisa da permissão `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` no máximo {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` a permissão `{shown}` não se aplica a um plug-in do tipo `{_1}` +@msg permission `{shown}` is listed twice a permissão `{shown}` aparece duas vezes +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede +@msg unknown permission `{shown}` permissão desconhecida `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado +@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 # Actions panel: action sets (#3712). New Set… Novo Conjunto… New Set Novo Conjunto @@ -5711,6 +5772,19 @@ axis Y Axis Eixo Y Action Options… Opções da Ação… Action Options Opções da Ação Playback Options… Opções de Reprodução… + Insert Stop… Inserir Parada… + Insert Stop Inserir Parada + Record Stop Gravar Parada + Allow Continue Permitir Continuar + Continue Continuar + Continue Action Continuar Ação + Delete Action Step Excluir Etapa da Ação + Press Play Current Selection in the Actions panel to go on. Para continuar, pressione Reproduzir Seleção Atual no painel Ações. + Stop: {message} Parada: {message} +@msg no action is waiting at a stop nenhuma ação está aguardando em uma parada +@msg message must be text message deve ser texto +@msg allowContinue must be true or false allowContinue deve ser true ou false +@msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada Playback Options Opções de Reprodução Accelerated Acelerada Step by Step Passo a Passo @@ -6152,6 +6226,7 @@ breakText Glyphs Glifos Gap 5 Lacuna 5 @msg {_1}: they came in as the page's art {_1}: foram importados como arte da página +@msg warped text objects that came in as drawn art, not as editable type: {_1} objetos de texto distorcidos importados como arte, não como texto editável: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} os objetos desenhados de forma diferente da própria página do arquivo foram importados como arte da página: {_1} @msg You must choose a side pattern. Você deve escolher um padrão lateral. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a83d9edf3..3732558f7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -82,6 +82,11 @@ 3 Color Job… Задание с 3 цветами… 30° Angles Углы 30° 3D and Materials 3D и материалы + Plane Плоскость + Extrude Выдавливание + Revolve Вращение + Inflate Надувание + Select objects, then pick an object type to give them a 3D effect. Выделите объекты, затем выберите тип объекта, чтобы придать им 3D-эффект. 3D Ribbon 3D-лента 45° Angles Углы 45° 60° Angles Углы 60° @@ -282,6 +287,8 @@ font-technology Variable Вариативные Show Cross Hairs Показать перекрестие Show Video Safe Areas Показать безопасные зоны видео Video Ruler Pixel Aspect Ratio: Соотношение сторон пикселя видео-линеек: + Fade Region Outside Artboard Затемнять область вне монтажной области + Update While Dragging Обновлять при перетаскивании Hide Video Rulers Скрыть видео-линейки Background Contents Содержимое фона Background Contents: Содержимое фона: @@ -1087,6 +1094,8 @@ font-technology Variable Вариативные Export for Screens Settings Параметры экспорта для экранов Export for Screens… Экспортировать для экранов… Export Keyboard Shortcuts… Экспортировать сочетания клавиш… + Export Keyboard Shortcuts as Text… Экспортировать сочетания клавиш как текст… + Export Text… Экспорт текста… Export Option Параметр экспорта Export PDF Экспортировать PDF Export PDF Presets Экспортировать наборы PDF @@ -1385,6 +1394,8 @@ file-filter Templates Шаблоны Grays Серые Grays and Neutrals Серые и нейтральные Grayscale Оттенки серого + High Color Много цветов + Low Color Мало цветов Grayscale images Изображения в оттенках серого Greek & Cyrillic Греческий и кириллица Green Зелёный @@ -1458,6 +1469,7 @@ file-filter Templates Шаблоны Step Backward Шаг назад Step Forward Шаг вперёд Clear History Очистить историю + New Document from State Новый документ из состояния Set History Limit… Задать предел истории… History States Состояния истории # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1852,6 +1864,9 @@ file-filter Templates Шаблоны Live Paint Selection Tool Инструмент «Выделение быстрой заливки» Live Shape Properties Свойства динамической фигуры Load Color Profile… Загрузить профиль цвета… + Load Character Styles… Загрузить стили символов… + Load Paragraph Styles… Загрузить стили абзацев… + Load All Styles… Загрузить все стили… Remove Color Profile… Удалить профиль цвета… Load Image into Clipboard Загрузить изображение в буфер обмена Load Metafile into Clipboard Загрузить метафайл в буфер обмена @@ -2721,6 +2736,14 @@ digits Malayalam Малаялам Relink Восстановить связь Relink Linked Files to the Document Восстановить связи связанных файлов с документом Relink to Folder… Восстановить связь с папкой… + Each Link Separately Каждую связь отдельно + Selected Instances Выбранные экземпляры + All Instances Все экземпляры + Missing Links from This Folder Отсутствующие связи из этой папки +@msg scope {v} is not each|selected|all|missingInFolder scope {v} не является одним из each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each и paths задаются вместе: paths перечисляет записи с ids и path вместо ids, path и folder +@msg paths entry {i} needs path and ids (object ids no other entry has) записи {i} в paths нужны path и ids (идентификаторы объектов, которых нет в других записях) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder принимает path: в папке этого файла ищутся остальные отсутствующие связи Relink… Восстановить связь… Find Linked Files Найти связанные файлы Find Linked Files in Folder… Найти связанные файлы в папке… @@ -2765,6 +2788,7 @@ digits Malayalam Малаялам Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замените его другим файлом или игнорируйте: его изображения продолжат показывать предварительный просмотр, сохранённый с документом. Replace Spine Заменить остов Replace Symbol Заменить символ + Reset Transformation Сбросить трансформацию Replace With Font Заменить шрифтом Replace With Font From Заменить шрифтом из System Система @@ -2781,6 +2805,8 @@ digits Malayalam Малаялам Reset OpenType Features Сбросить функции OpenType Reset Panel Сбросить панель Reset Preferences Сбросить настройки + Search Preferences Поиск в настройках + No settings found Настройки не найдены Reset Profiles Сбросить профили Rotate View Повернуть вид Reset Rotate View Сбросить поворот вида @@ -2836,6 +2862,7 @@ digits Malayalam Малаялам Rotate Tool Инструмент «Поворот» Rotate View Tool Инструмент «Поворот вида» Trackpad Gesture to Rotate View Поворачивать вид жестом трекпада + Hold a Tool Shortcut to Use the Tool Temporarily Временно использовать инструмент, удерживая его клавишу Rotate… Повернуть… Rotation Поворот Roughen… Огрубление… @@ -3277,6 +3304,7 @@ digits Malayalam Малаялам Spaces Пробелы Spacing Guides Направляющие интервалов Invoke Distance Guides Вызывать направляющие расстояний + Snap to Last Location Привязать к последнему положению Spacing: Интервал: Spanish Испанский Specified Distance Заданное расстояние @@ -3966,6 +3994,9 @@ axis Both Оба Put back in the toolbar Вернуть на панель инструментов Float Panel Сделать панель плавающей Dock Panel Прикрепить панель + Dock to Top Прикрепить сверху + Dock to Bottom Прикрепить снизу + Dock Control Bar Прикрепить панель управления Drag to float the toolbar Перетащите, чтобы сделать панель инструментов плавающей Drag to the window's left edge to dock Перетащите к левому краю окна для прикрепления Float Tool Group Сделать группу инструментов плавающей @@ -4413,6 +4444,7 @@ axis Both Оба @msg nothing to save: the document has no visible art нечего сохранять: в документе нет видимого рисунка @msg nothing to undo нечего отменять @msg no history to clear нет истории для очистки +@msg no history state {v} нет состояния истории {v} @msg nothing to warp нечего деформировать @msg nothing to wrap нечего обтекать @msg object {0} cannot have children объект {0} не может иметь потомков @@ -4661,6 +4693,7 @@ axis Both Оба @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes не удалось декодировать {n} изображение: оно показано как перечёркнутая рамка|не удалось декодировать {n} изображения: они показаны как перечёркнутые рамки|не удалось декодировать {n} изображений: они показаны как перечёркнутые рамки @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} связанное изображение изменилось на диске: «Обновить связи» покажет новую версию|{n} связанных изображения изменились на диске: «Обновить связи» покажет новые версии|{n} связанных изображений изменились на диске: «Обновить связи» покажет новые версии @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Обновлён {n} связанный файл, изменённый на диске|Обновлено {n} связанных файла, изменённых на диске|Обновлено {n} связанных файлов, изменённых на диске +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings в документе задан вылет, который этот файл PDF/X не включает: включите «Использовать параметры вылета документа» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Экспортировано: {what} с {n} примечанием: {first}|Экспортировано: {what} с {n} примечаниями: {first}|Экспортировано: {what} с {n} примечаниями: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} файл шрифта теперь в {folder}|{n} файла шрифтов теперь в {folder}|{n} файлов шрифтов теперь в {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} файл шрифта теперь в {folder}; не удалось добавить {failed}: {reason}|{n} файла шрифтов теперь в {folder}; не удалось добавить {failed}: {reason}|{n} файлов шрифтов теперь в {folder}; не удалось добавить {failed}: {reason} @@ -4775,6 +4808,7 @@ axis Both Оба Exit Выйти @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — это ссылка на шаг {r}, которую разрешают только `run` и `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` должен быть массивом идентификаторов объектов, а не {v} +@msg `colors` must be an array of colors, not {v} `colors` должен быть массивом цветов, а не {v} @msg `corners` must be an array of anchor indices, not {v} `corners` должен быть массивом индексов опорных точек, а не {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$) @@ -5399,6 +5433,33 @@ axis Y Axis Ось Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения? + ID: {id} ID: {id} + Version: {version} Версия: {version} + File SHA-256: {hash} SHA-256 файла: {hash} + Read the objects it is given Читать переданные ему объекты + Change, add and delete objects in the document Изменять, добавлять и удалять объекты в документе + Keep data of its own with the document Хранить свои данные в документе + Change how the objects its effect is applied to are drawn Изменять то, как рисуются объекты, к которым применён его эффект + Write the document as a file in its own format Записывать документ в файл собственного формата + Show its progress, see Cancel and write log lines Показывать свой ход выполнения, замечать отмену и записывать строки журнала +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `progress` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль импортирует `env.{name}` с другим типом, чем у хоста: у хоста тип {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только `env.vc_progress`, `env.vc_cancelled` и `env.vc_log` (уровень API модулей 2), без WASI, файлов и сети +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` равно {_1}; этот хост поддерживает уровни API модулей от 1 до {API_LEVEL} +@msg `grant` must be a list of permission names `grant` должен быть списком имён разрешений +@msg `grant` names an unknown permission `{shown}` `grant` называет неизвестное разрешение `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю вида `{_1}` нужно разрешение `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` не более {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` разрешение `{shown}` не относится к модулю вида `{_1}` +@msg permission `{shown}` is listed twice разрешение `{shown}` указано дважды +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт +@msg unknown permission `{shown}` неизвестное разрешение `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено # Actions panel: action sets (#3712). New Set… Новый набор… New Set Новый набор @@ -5407,6 +5468,19 @@ axis Y Axis Ось Y Action Options… Параметры действия… Action Options Параметры действия Playback Options… Параметры воспроизведения… + Insert Stop… Вставить остановку… + Insert Stop Вставить остановку + Record Stop Записать остановку + Allow Continue Разрешить продолжение + Continue Продолжить + Continue Action Продолжить действие + Delete Action Step Удалить шаг действия + Press Play Current Selection in the Actions panel to go on. Чтобы продолжить, нажмите «Воспроизвести текущее выделение» на панели «Действия». + Stop: {message} Остановка: {message} +@msg no action is waiting at a stop ни одно действие не ожидает на остановке +@msg message must be text message должно быть текстом +@msg allowContinue must be true or false allowContinue должно быть true или false +@msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой Playback Options Параметры воспроизведения Accelerated Ускоренно Step by Step Пошагово @@ -6211,6 +6285,7 @@ breakText Glyphs Глифы Gap 5 Зазор 5 @msg {_1}: they came in as the page's art {_1}: импортированы как графика страницы +@msg warped text objects that came in as drawn art, not as editable type: {_1} деформированные текстовые объекты, импортированные как графика, а не как редактируемый текст: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} объекты, которые отрисовывались иначе, чем собственная страница файла, импортированы как графика страницы: {_1} @msg You must choose a side pattern. Выберите боковой узор. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 780d23bea..58ceab701 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -72,6 +72,11 @@ 3 Color Job… 3-färgsjobb… 30° Angles 30°-vinklar 3D and Materials 3D och material + Plane Plan + Extrude Extrudera + Revolve Rotera runt axel + Inflate Blås upp + Select objects, then pick an object type to give them a 3D effect. Markera objekt och välj sedan en objekttyp för att ge dem en 3D-effekt. 3D Ribbon 3D-band 45° Angles 45°-vinklar 60° Angles 60°-vinklar @@ -273,6 +278,8 @@ font-technology Variable Variabla Show Cross Hairs Visa hårkors Show Video Safe Areas Visa videosäkra områden Video Ruler Pixel Aspect Ratio: Pixelproportion för videolinjaler: + Fade Region Outside Artboard Tona ned området utanför ritytan + Update While Dragging Uppdatera vid dragning Hide Video Rulers Dölj videolinjaler Background Contents Bakgrundsinnehåll Background Contents: Bakgrundsinnehåll: @@ -1068,6 +1075,8 @@ font-technology Variable Variabla Export for Screens Settings Inställningar för Exportera för skärmar Export for Screens… Exportera för skärmar… Export Keyboard Shortcuts… Exportera kortkommandon… + Export Keyboard Shortcuts as Text… Exportera kortkommandon som text… + Export Text… Exportera text… Export Option Exportalternativ Export PDF Exportera PDF Export PDF Presets Exportera PDF-förinställningar @@ -1335,6 +1344,8 @@ font-technology Variable Variabla Grays Gråtoner Grays and Neutrals Gråtoner och neutrala toner Grayscale Gråskala + High Color Många färger + Low Color Få färger Grayscale images Gråskalebilder Greek & Cyrillic Grekiska och kyrilliska Green Grön @@ -1407,6 +1418,7 @@ font-technology Variable Variabla Step Backward Stega bakåt Step Forward Stega framåt Clear History Rensa historik + New Document from State Nytt dokument från tillstånd Set History Limit… Ange historikgräns… History States Historiktillstånd # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1767,6 +1779,9 @@ font-technology Variable Variabla Live Paint Selection Tool Markeringsverktyg för direktmålning Live Shape Properties Egenskaper för direktform Load Color Profile… Läs in färgprofil… + Load Character Styles… Läs in teckenstilar… + Load Paragraph Styles… Läs in styckestilar… + Load All Styles… Läs in alla stilar… Remove Color Profile… Ta bort färgprofil… Load Image into Clipboard Läs in bild till urklipp Load Metafile into Clipboard Läs in metafil till urklipp @@ -2629,6 +2644,14 @@ digits Malayalam Malayalam Relink Länka om Relink Linked Files to the Document Länka om länkade filer till dokumentet Relink to Folder… Länka om till mapp… + Each Link Separately Varje länk separat + Selected Instances Markerade instanser + All Instances Alla instanser + Missing Links from This Folder Saknade länkar från den här mappen +@msg scope {v} is not each|selected|all|missingInFolder scope {v} är inte each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each och paths hör ihop: paths listar poster med ids och path i stället för ids, path och folder +@msg paths entry {i} needs path and ids (object ids no other entry has) post {i} i paths behöver path och ids (objekt-id:n som ingen annan post har) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder tar path: de andra saknade länkarna söks i den filens mapp Relink… Länka om… Find Linked Files Sök länkade filer Find Linked Files in Folder… Sök länkade filer i mapp… @@ -2673,6 +2696,7 @@ digits Malayalam Malayalam 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 Replace Symbol Ersätt symbol + Reset Transformation Återställ transformering Replace With Font Ersätt med typsnitt Replace With Font From Ersätt med typsnitt från System System @@ -2689,6 +2713,8 @@ digits Malayalam Malayalam Reset OpenType Features Återställ OpenType-funktioner Reset Panel Återställ panel Reset Preferences Återställ inställningar + Search Preferences Sök i inställningar + No settings found Inga inställningar hittades Reset Profiles Återställ profiler Reset Rotate View Återställ Rotera vy Rotate View to Selection Rotera vyn efter markeringen @@ -2743,6 +2769,7 @@ digits Malayalam Malayalam Rotate Tool Rotationsverktyg Rotate View Tool Verktyget Rotera vy Trackpad Gesture to Rotate View Rotera vyn med en gest på styrplattan + Hold a Tool Shortcut to Use the Tool Temporarily Håll ned ett verktygs kortkommando för att använda verktyget tillfälligt Rotate… Rotera… Rotation Rotation Roughen… Ojämn… @@ -3168,6 +3195,7 @@ digits Malayalam Malayalam Spaces Blanksteg Spacing Guides Avståndsstödlinjer Invoke Distance Guides Anropa mätstödlinjer för avstånd + Snap to Last Location Fäst mot senaste plats Spacing: Avstånd: Spanish Spanska Specified Distance Angivet avstånd @@ -3846,6 +3874,9 @@ axis Both Båda Put back in the toolbar Lägg tillbaka i verktygsfältet Float Panel Gör panelen flytande Dock Panel Docka panel + Dock to Top Docka överst + Dock to Bottom Docka nederst + Dock Control Bar Docka kontrollfältet Drag to float the toolbar Dra för att göra verktygsfältet flytande Drag to the window's left edge to dock Dra till fönstrets vänsterkant för att docka Float Tool Group Gör verktygsgruppen flytande @@ -4281,6 +4312,7 @@ axis Both Båda @msg nothing to save: the document has no visible art inget att spara: dokumentet har ingen synlig grafik @msg nothing to undo inget att ångra @msg no history to clear ingen historik att rensa +@msg no history state {v} inget historiktillstånd {v} @msg nothing to warp inget att böja @msg nothing to wrap inget att radbryta @msg object {0} cannot have children objekt {0} kan inte ha underordnade objekt @@ -4523,6 +4555,7 @@ axis Both Båda @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} bild kunde inte avkodas och visas som en överkryssad ruta|{n} bilder kunde inte avkodas och visas som överkryssade rutor @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} länkad bild har ändrats på disken: Uppdatera länkar visar den nya versionen|{n} länkade bilder har ändrats på disken: Uppdatera länkar visar de nya versionerna @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Uppdaterade {n} länkad fil som ändrats på disken|Uppdaterade {n} länkade filer som ändrats på disken +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokumentet anger ett utfall som den här PDF/X-filen utelämnar: slå på ”Använd dokumentets inställningar för utfall” @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Exporterade {what} med {n} anteckning: {first}|Exporterade {what} med {n} anteckningar: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} typsnittsfil finns nu i {folder}|{n} typsnittsfiler finns nu i {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} typsnittsfil finns nu i {folder}; {failed} kunde inte läggas till: {reason}|{n} typsnittsfiler finns nu i {folder}; {failed} kunde inte läggas till: {reason} @@ -4653,6 +4686,7 @@ axis Both Båda ICF Box ICF-ruta @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` är stegreferensen {r}, som bara `run` och `command_batch` löser upp @msg `ids` must be an array of object ids, not {v} `ids` måste vara en array med objekt-id:n, inte {v} +@msg `colors` must be an array of colors, not {v} `colors` måste vara en array med färger, inte {v} @msg `corners` must be an array of anchor indices, not {v} `corners` måste vara en array med index för ankarpunkter, inte {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: resultatet från steg {i} har inget sådant värde (skriv $$ för ett bokstavligt $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: det finns inget steg {i} före det här (stegen räknas från 0; skriv $$ för ett bokstavligt $) @@ -5811,6 +5845,33 @@ axis Y Axis Y-axel @msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy` @msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? + ID: {id} ID: {id} + Version: {version} Version: {version} + File SHA-256: {hash} Filens SHA-256: {hash} + Read the objects it is given Läsa objekten den får + Change, add and delete objects in the document Ändra, lägga till och ta bort objekt i dokumentet + Keep data of its own with the document Spara egna data med dokumentet + Change how the objects its effect is applied to are drawn Ändra hur objekten som dess effekt används på ritas + Write the document as a file in its own format Skriva dokumentet som en fil i sitt eget format + Show its progress, see Cancel and write log lines Visa sitt förlopp, se när det avbryts och skriva loggrader +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `progress` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modulen importerar `env.{name}` med en annan typ än värdens: värdens typ är {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera `env.vc_progress`, `env.vc_cancelled` och `env.vc_log` (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` är {_1}; den här värden stöder plugin-API-nivå 1 till {API_LEVEL} +@msg `grant` must be a list of permission names `grant` måste vara en lista med behörighetsnamn +@msg `grant` names an unknown permission `{shown}` `grant` anger en okänd behörighet `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` ett plugin av typen `{_1}` behöver behörigheten `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` högst {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` behörigheten `{shown}` gäller inte ett plugin av typen `{_1}` +@msg permission `{shown}` is listed twice behörigheten `{shown}` står två gånger +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte +@msg unknown permission `{shown}` okänd behörighet `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades +@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 # Actions panel: action sets (#3712). New Set… Ny uppsättning… New Set Ny uppsättning @@ -5819,6 +5880,19 @@ axis Y Axis Y-axel Action Options… Alternativ för åtgärd… Action Options Alternativ för åtgärd Playback Options… Uppspelningsalternativ… + Insert Stop… Infoga stopp… + Insert Stop Infoga stopp + Record Stop Spela in stopp + Allow Continue Tillåt fortsättning + Continue Fortsätt + Continue Action Fortsätt åtgärd + Delete Action Step Radera åtgärdssteg + Press Play Current Selection in the Actions panel to go on. Fortsätt genom att trycka på Spela upp aktuell markering i panelen Åtgärder. + Stop: {message} Stopp: {message} +@msg no action is waiting at a stop ingen åtgärd väntar vid ett stopp +@msg message must be text message måste vara text +@msg allowContinue must be true or false allowContinue måste vara true eller false +@msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp Playback Options Uppspelningsalternativ Accelerated Accelererad Step by Step Steg för steg @@ -6286,6 +6360,7 @@ breakText Glyphs Glyfer Gap 5 Mellanrum 5 @msg {_1}: they came in as the page's art {_1}: de importerades som sidans bild +@msg warped text objects that came in as drawn art, not as editable type: {_1} förvrängda textobjekt som importerades som bild och inte som redigerbar text: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objekt som ritades annorlunda än filens egen sida importerades som sidans bild: {_1} @msg You must choose a side pattern. Du måste välja ett sidomönster. @@ -7698,7 +7773,6 @@ digits Hindi Hindi @msg the library has no item `{other}` biblioteket har inget objekt `{other}` @msg the library holds {MAX_GROUPS} groups already biblioteket innehåller redan {MAX_GROUPS} grupper @msg the library holds {MAX_ITEMS} items of this kind already biblioteket innehåller redan {MAX_ITEMS} objekt av den här typen -@msg the module imports `{_1}.{_2}`, but plug-ins get no host imports (no WASI, files or network) modulen importerar `{_1}.{_2}`, men plugins får inga värdimporter (ingen WASI, inga filer eller nätverk) @msg the module must export its linear memory as `memory` modulen måste exportera sitt linjära minne som `memory` @msg the name is empty namnet är tomt @msg the object has no fill, stroke or effect objektet har ingen fyllning, kontur eller effekt diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 0f960961e..ed95d4e8e 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1606,6 +1606,19 @@ fn messages_with_a_count_take_the_plural_form() { assert_eq!(message(cs, "Packaged 21 files"), "Zabaleno 21 souborů"); } +/// The import's note for warped type that opened as drawn art is translated in every complete +/// catalog. It is built from a constant, which the scan of message literals doesn't see. +#[test] +fn warped_type_note_is_translated() { + let note = "warped text objects that came in as drawn art, not as editable type: 3"; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let shown = message(lang, note); + assert_ne!(shown, note, "{code}"); + assert!(shown.contains('3'), "{code}: {shown}"); + } +} + /// The Gamut Check status line names a count of 1 in the singular and other counts in the plural, /// and every complete catalog translates it, in the form its plural rule picks. #[test] @@ -1903,6 +1916,20 @@ fn dataset_import_repeated_column_error_is_translated() { } } +/// The warning for a PDF/X file that leaves out the document's bleed is translated in every catalog, +/// alone and in the status bar's note. +#[test] +fn pdfx_bleed_warning_is_translated() { + let w = vectorcraft_engine::cmd::fileio::pdf::BLEED_LEFT_OUT; + let status = format!("Saved /tmp/a.pdf with 1 note: {w}"); + // A catalog's `{a}: {b}` row can translate the colon alone, so the check looks for the English words. + for lang in Lang::all().filter(|l| l.code() != Lang::EN.code()) { + for shown in [message(lang, w), message(lang, &status)] { + assert!(!shown.contains("leaves out") && !shown.contains("turn on"), "{}: {shown}", lang.code()); + } + } +} + /// The errors of saving, renaming and deleting a tracing preset are translated in every language /// that translates every message. #[test] @@ -2062,6 +2089,113 @@ fn shared_param_check_errors_show_their_values_as_given() { } } +/// The spans of `s` between pairs of backticks, such as a parameter key or a command id; none when +/// its backticks don't pair up. +fn backticked(s: &str) -> Vec<&str> { + let parts: Vec<&str> = s.split('`').collect(); + if parts.len().is_multiple_of(2) { + return Vec::new(); + } + parts.into_iter().skip(1).step_by(2).filter(|t| !t.is_empty()).collect() +} + +#[test] +fn backticked_finds_the_spans_between_paired_backticks() { + assert_eq!(backticked("`ids` must be an array, see `plugin.list`"), ["ids", "plugin.list"]); + assert_eq!(backticked("no backticks"), Vec::<&str>::new()); + assert_eq!(backticked("a lone ` backtick"), Vec::<&str>::new()); +} + +/// A translated row keeps each backticked span of its English text, in backticks and as the +/// English spells it: the span names a parameter key, a command id or a placeholder, which the +/// command reads as written. +#[test] +fn translations_keep_the_backticked_names_of_their_english() { + let mut missing = Vec::new(); + for l in &LANGUAGES { + let (entries, _) = parse_entries(l.source); + for (ctx, src, tr) in &entries { + let plural = ctx == "@plural" || ctx == "@msgplural"; + let english = if plural { src.rsplit('|').next().unwrap_or(src) } else { src.as_str() }; + let forms: Vec<&str> = if plural { tr.split('|').collect() } else { vec![tr.as_str()] }; + for form in forms { + for token in backticked(english) { + if !form.contains(&format!("`{token}`")) { + missing.push(format!("{}: `{token}` in {src:?} -> {form:?}", l.code)); + } + } + } + } + } + assert!(missing.is_empty(), "{} rows drop a backticked name:\n{}", missing.len(), missing.join("\n")); +} + +/// The spans of `s` between pairs of straight double quotes outside its backticked spans, such as +/// a value a parameter takes; none when those quotes don't pair up. +fn double_quoted(s: &str) -> Vec<&str> { + let outside: Vec<&str> = s.split('`').step_by(2).collect(); + let quotes: usize = outside.iter().map(|part| part.matches('"').count()).sum(); + if !quotes.is_multiple_of(2) { + return Vec::new(); + } + let mut spans = Vec::new(); + let mut open = false; + for part in outside { + for (i, piece) in part.split('"').enumerate() { + if i > 0 { + open = !open; + } + if open && !piece.is_empty() { + spans.push(piece); + } + } + } + spans +} + +/// Whether `text` holds `word` with no letter or digit right before or after it. +fn holds_word(text: &str, word: &str) -> bool { + text.match_indices(word).any(|(at, _)| { + let before = text[..at].chars().next_back().is_none_or(|c| !c.is_alphanumeric()); + let after = text[at + word.len()..].chars().next().is_none_or(|c| !c.is_alphanumeric()); + before && after + }) +} + +#[test] +fn double_quoted_finds_the_spans_between_paired_quotes() { + assert_eq!(double_quoted(r#"span must be "object" or "selection", got {v}"#), ["object", "selection"]); + assert_eq!(double_quoted(r#"`{"a": 1}` is "left""#), ["left"]); + assert_eq!(double_quoted(r#"a lone " quote"#), Vec::<&str>::new()); + assert!(holds_word("“auto” veya “optical”", "auto")); + assert!(!holds_word(r#""content""#, "contents")); + assert!(!holds_word(r#""none""#, "one")); +} + +/// A translated status or error message keeps each value its English text puts in double quotes, +/// spelled as the English spells it, since the value is what a command reads; the quote marks may +/// be the language's own. Menu and control labels quote names of the interface, which translate. +#[test] +fn translated_messages_keep_the_double_quoted_values_of_their_english() { + let mut missing = Vec::new(); + for l in &LANGUAGES { + let (entries, _) = parse_entries(l.source); + for (ctx, src, tr) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg" || ctx == "@msgplural") { + let plural = ctx == "@msgplural"; + let english = if plural { src.rsplit('|').next().unwrap_or(src) } else { src.as_str() }; + let forms: Vec<&str> = if plural { tr.split('|').collect() } else { vec![tr.as_str()] }; + for form in forms { + for value in double_quoted(english) { + if !holds_word(form, value) { + missing.push(format!("{}: \"{value}\" in {src:?} -> {form:?}", l.code)); + } + } + } + } + } + assert!(missing.is_empty(), "{} rows drop a double-quoted value:\n{}", missing.len(), missing.join("\n")); +} + /// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists /// the messages each one lacks. const COMPLETE_MESSAGES: &[&str] = &[ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 55a9cdc73..9385b30cc 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -69,6 +69,11 @@ 3 Color Job… 3 Renkli İş… 30° Angles 30° Açılar 3D and Materials 3D ve Malzemeler + Plane Düzlem + Extrude Ekstrüzyon + Revolve Döndür + Inflate Şişir + Select objects, then pick an object type to give them a 3D effect. Nesneleri seçin, ardından onlara 3D efekti vermek için bir nesne türü seçin. 3D Ribbon 3D Şerit 45° Angles 45° Açılar 60° Angles 60° Açılar @@ -269,6 +274,8 @@ font-technology Variable Değişken Show Cross Hairs Artı Çizgilerini Göster Show Video Safe Areas Video Güvenli Alanlarını Göster Video Ruler Pixel Aspect Ratio: Video Cetveli Piksel En Boy Oranı: + Fade Region Outside Artboard Çalışma Yüzeyi Dışındaki Bölgeyi Karart + Update While Dragging Sürüklerken Güncelle Hide Video Rulers Video Cetvellerini Gizle Background Contents Arka Plan İçeriği Background Contents: Arkaplan İçeriği: @@ -1060,6 +1067,8 @@ font-technology Variable Değişken Export for Screens Settings Ekran Ayarları için Dışa Aktarma Export for Screens… Ekranlar için Dışa Aktarma… Export Keyboard Shortcuts… Klavye Kısayollarını Dışa Aktar… + Export Keyboard Shortcuts as Text… Klavye Kısayollarını Metin Olarak Dışa Aktar… + Export Text… Metni Dışa Aktar… Export Option Dışa Aktarma Seçeneği Export PDF PDF'yi dışa aktar Export PDF Presets PDF Hazır Ayarlarını Dışa Aktar @@ -1299,6 +1308,8 @@ font-technology Variable Değişken Grays Griler Grays and Neutrals Griler ve Nötrler Grayscale Gri tonlamalı + High Color Çok Renk + Low Color Az Renk Grayscale images Gri tonlamalı görüntüler Greek & Cyrillic Yunanca ve Kiril Green Yeşil @@ -1663,6 +1674,9 @@ font-technology Variable Değişken Live Paint Selection Tool Canlı Boyama Seçim Aracı Live Shape Properties Canlı Şekil Özellikleri Load Color Profile… Renk Profilini Yükle… + Load Character Styles… Karakter Stillerini Yükle… + Load Paragraph Styles… Paragraf Stillerini Yükle… + Load All Styles… Tüm Stilleri Yükle… Load Image into Clipboard Resmi Panoya Yükle Load Metafile into Clipboard Meta Dosyasını Panoya Yükle Load PDF into Clipboard PDF'yi Panoya Yükle @@ -2500,6 +2514,14 @@ digits Malayalam Malayalam Relink Yeniden bağla Relink Linked Files to the Document Bağlantılı Dosyaları Belgeye Yeniden Bağlama Relink to Folder… Klasöre Yeniden Bağlan… + Each Link Separately Her Bağlantıyı Ayrı Ayrı + Selected Instances Seçili Örnekler + All Instances Tüm Örnekler + Missing Links from This Folder Bu Klasördeki Eksik Bağlantılar +@msg scope {v} is not each|selected|all|missingInFolder scope {v}, each|selected|all|missingInFolder değerlerinden biri değil +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each ve paths birlikte kullanılır: paths, ids, path ve folder yerine ids ve path içeren girdileri listeler +@msg paths entry {i} needs path and ids (object ids no other entry has) paths girdisi {i} için path ve ids gerekir (başka hiçbir girdide olmayan nesne kimlikleri) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder path alır: diğer eksik bağlantılar o dosyanın klasöründe aranır Relink… Yeniden bağla… Find Linked Files Bağlantılı Dosyaları Bul Find Linked Files in Folder… Klasördeki Bağlantılı Dosyaları Bul… @@ -2543,6 +2565,7 @@ digits Malayalam Malayalam 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 Replace Symbol Sembolü Değiştir + Reset Transformation Dönüşümü Sıfırla Replace With Font Yazı Tipiyle Değiştir Replace With Font From Değiştirilecek Yazı Tipi Kaynağı System Sistem @@ -2559,6 +2582,8 @@ digits Malayalam Malayalam Reset OpenType Features OpenType Özelliklerini Sıfırla Reset Panel Paneli Sıfırla Reset Preferences Tercihleri Sıfırla + Search Preferences Tercihlerde Ara + No settings found Ayar bulunamadı Reset Profiles Profilleri Sıfırla Reset Rotate View Döndürme Görünümünü Sıfırla Rotate View to Selection Görünümü Seçime Döndür @@ -2792,6 +2817,7 @@ digits Malayalam Malayalam Auto Leading: Otomatik Satır Aralığı: Cancel Export Dışa Aktarmayı İptal Et Clear History Geçmişi Temizle + New Document from State Durumdan Yeni Belge Continue in Background Arka Planda Devam Et Delete Light Işığı Sil Desired İstenen @@ -2815,7 +2841,7 @@ digits Malayalam Malayalam Apply {name} “{name}” Stilini Uygula Redefine {name} “{name}” Stilini Yeniden Tanımla @msg `Custom` stands for changed settings: save the preset under another name `Custom` değiştirilmiş ayarları ifade eder: ön ayarı başka bir adla kaydedin -@msg `{key}` must be an array of these names: {_1} "{key}" şu adlardan oluşan bir dizi olmalıdır: {_1} +@msg `{key}` must be an array of these names: {_1} `{key}` şu adlardan oluşan bir dizi olmalıdır: {_1} Search commands and tools Komutları ve araçları arayın Search commands and tools… Komutları ve araçları arayın… Search Commands… Arama Komutları… @@ -3070,6 +3096,7 @@ digits Malayalam Malayalam Spaces Alanlar Spacing Guides Aralık Kılavuzları Invoke Distance Guides Mesafe Kılavuzlarını Çağır + Snap to Last Location Son Konuma Yasla Spacing: Aralık: Spanish İspanyolca Specified Distance Belirtilen Mesafe @@ -3734,12 +3761,15 @@ axis Both Her İkisi Put back in the toolbar Araç çubuğuna geri yerleştir Float Panel Paneli Yüzer Yap Dock Panel Bağlantı İstasyonu Paneli + Dock to Top Üste yerleştir + Dock to Bottom Alta yerleştir + Dock Control Bar Kontrol çubuğunu yerleştir Drag to float the toolbar Araç çubuğunu ayırmak için sürükleyin Drag to the window's left edge to dock Sabitlemek için pencerenin sol kenarına sürükleyin Float Tool Group Araç Grubunu Yüzer Yap @msg no library `{id}` `{id}` kütüphanesi yok @msg select some type first önce bir tür seçin -@msg kerning must be a number, "auto", or "optical" Karakter aralığı sayı, “auto” veya “optical” olmalıdır +@msg kerning must be a number, "auto", or "optical" kerning bir sayı, “auto” veya “optical” olmalıdır @msg the selection has no gradient fill seçimin degrade dolgusu yok @msg the selection has no gradient stroke seçimin degrade konturu yok @msg the selection has no pattern fill seçimin desen dolgusu yok @@ -3764,7 +3794,7 @@ axis Both Her İkisi @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same üzerlerindeki görseller sayfanınkinden %{_1} oranında farklıdır: yine de düzenleme verilerinden gelirler @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer dosyanın katmanları okunmadı ({_1}): tek katmanda kendi sayfası olarak geliyor @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part dosyanın katmanları düzenleme verilerinden okunmadı ({_1}): PDF kısmından geliyorlar -@msg `to` must be "corner" or "smooth" "to" "köşe" veya "pürüzsüz" olmalıdır +@msg `to` must be "corner" or "smooth" `to` "corner" veya "smooth" olmalıdır @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` "emBox", "capHeight", "xHeight" veya "icfBox" olmalıdır @msg BMP files are RGB or grayscale: colorModel rgb or gray BMP dosyaları RGB veya gri tonlamalıdır: colorModel rgb veya gri @msg Blend colors: select at least three objects with filled colours Renkleri karıştır: dolgulu renklere sahip en az üç nesne seçin @@ -3836,35 +3866,35 @@ axis Both Her İkisi @msg Targa files are RGB: colorModel rgb Targa dosyaları RGB'dir: colorModel rgb @msg Text Wrap: select a wrap object Metin Sarma: bir sarma nesnesi seçin @msg This shortcut file is in a form VectorCraft can't read. Bu kısayol dosyası VectorCraft'ın okuyamayacağı bir biçimdedir. -@msg `aspect` must be a number (%) "boyut" bir sayı olmalıdır (%) +@msg `aspect` must be a number (%) `aspect` bir sayı olmalıdır (%) @msg `at` must be [x, y] `at` [x, y] olmalıdır -@msg `color` must be a colour "renk" bir renk olmalıdır -@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `komut`, menü öğesi bir iletişim kutusu açan bir komut olmalıdır (bkz. ui.menuDialog) -@msg `freeform.points` must be an array "freeform.points" bir dizi olmalıdır -@msg `freeform.lines` must be an array "freeform.lines" bir dizi olmalıdır -@msg `gradient` must be an object "gradyan" bir nesne olmalıdır +@msg `color` must be a colour `color` bir renk olmalıdır +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command`, menü öğesi bir iletişim kutusu açan bir komut olmalıdır (bkz. ui.menuDialog) +@msg `freeform.points` must be an array `freeform.points` bir dizi olmalıdır +@msg `freeform.lines` must be an array `freeform.lines` bir dizi olmalıdır +@msg `gradient` must be an object `gradient` bir nesne olmalıdır @msg `groupColors` must be an array `groupColors` bir dizi olmalıdır -@msg `library` is a library id or name (see {list}) 'kitaplık' bir kitaplık kimliği veya adıdır (bkz. {list}) -@msg `mode` applies to freeform gradients "mod" serbest biçimli degradeler için geçerlidir +@msg `library` is a library id or name (see {list}) `library` bir kitaplık kimliği veya adıdır (bkz. {list}) +@msg `mode` applies to freeform gradients `mode` serbest biçimli degradeler için geçerlidir @msg `preserve` must be an object `preserve` bir nesne olmalıdır @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 "duraklar" bir dizi 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 `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 @msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` bir renk değildir (#rrggbb veya önceden ayarlanmış bir ad) @msg `{id}` has no envelope dialog `{id}`de zarf iletişim kutusu yok @msg `{keep}` is not a side of this axis `{keep}` bu eksenin bir tarafı değil -@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` "{key}", "auto" veya "en" veya "zh-hant" gibi bir dil kodu olmalıdır -@msg `{key}` must be a #rrggbb colour "{key}" #rrggbb rengi olmalıdır -@msg `{key}` must be a number "{key}" bir sayı olmalıdır -@msg `{key}` must be a string "{key}" bir dize olmalıdır +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}`, `auto` ya da `en` veya `zh-hant` gibi bir dil kodu olmalıdır +@msg `{key}` must be a #rrggbb colour `{key}` bir #rrggbb rengi olmalıdır +@msg `{key}` must be a number `{key}` bir sayı olmalıdır +@msg `{key}` must be a string `{key}` bir dize olmalıdır @msg `{key}` must be a whole number `{key}` bir tam sayı olmalıdır @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında bir tam sayı olmalıdır ({x}'yi aldı) @msg `{key}` must be between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında olmalıdır ({x}'yi aldı) @msg `{key}` must be one of {_1} `{key}`, {_1}'den biri olmalıdır -@msg `{key}` must be true or false "{key}" doğru veya yanlış olmalıdır +@msg `{key}` must be true or false `{key}` true veya false olmalıdır @msg `top` and `below` can't both be true `top` ve `below` aynı anda true olamaz @msg `select` must be true or false `select` true veya false olmalıdır @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` ve `maxAnchors` tam sayı olmalıdır @@ -3872,8 +3902,8 @@ axis Both Her İkisi @msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` [x, y, width, height] olmalıdır: dört sonlu sayı, genişlik ve yükseklik negatif olmamalı @msg `{min_key}` is greater than `{max_key}` `{min_key}`, `{max_key}` değerinden büyük @msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` şunlardan en az birini verin: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` -@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe "mod" gri tonlamalı, rgb, hsb, cmyk veya web uyumlu olmalıdır -@msg `{k}` must be [x, y] "{k}" [x, y] olmalıdır +@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` grayscale, rgb, hsb, cmyk veya websafe olmalıdır +@msg `{k}` must be [x, y] `{k}` [x, y] olmalıdır @msg `{name}` has no options `{name}`nin seçeneği yok @msg `{name}` is a built-in workspace; choose another name `{name}` yerleşik bir çalışma alanıdır; başka bir isim seç @msg `{name}` is a process colour: tints apply to global and spot colours `{name}` bir proses renktir: renk tonları global ve spot renklere uygulanır @@ -3920,7 +3950,7 @@ axis Both Her İkisi @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: yok, resim veya tür @msg artboard {0} does not exist {0} çalışma yüzeyi mevcut değil @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard bu ölçekte çizim {w} × {h} nk, kanvasın sığabileceğinden daha fazladır: daha küçük bir ölçek seçin veya çalışma yüzeyine sığdırın -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` eksen "{v}" değil, "dikey", "yatay" veya derece cinsinden bir açı olmalıdır +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis "vertical", "horizontal" veya derece cinsinden bir açı olmalıdır, `{v}` değil @msg background {other}: transparent, white, black or a colour such as "#ff8800" arka plan {other}: şeffaf, beyaz, siyah veya "#ff8800" gibi bir renk @msg bad color {v} kötü renk {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio 0,1 ile 10 arasında bir sayı olmalıdır @@ -3934,9 +3964,9 @@ axis Both Her İkisi @msg too many changed shortcuts değiştirilen kısayol sayısı çok fazla @msg bad stream length Kötü akış uzunluğu @msg bad trailer kötü römork -@msg balance must be between 0 and 100 bakiye 0 ile 100 arasında olmalıdır +@msg balance must be between 0 and 100 balance 0 ile 100 arasında olmalıdır @msg batch step {_1} (`{id}`) failed: {e} toplu adım {_1} (`{id}`) başarısız oldu: {e} -@msg brightness must be dark|mediumDark|mediumLight|light parlaklık koyu|ortaKoyu|ortaAçık|açık olmalıdır +@msg brightness must be dark|mediumDark|mediumLight|light brightness dark|mediumDark|mediumLight|light olmalıdır @msg can't encode the document: {0} belge kodlanamıyor: {0} @msg can't parse shortcut `{c}` `{c}` kısayolu ayrıştırılamıyor @msg can't parse shortcut `{s}` for `{k}` `{k}` için `{s}` kısayolu ayrıştırılamıyor @@ -3950,7 +3980,7 @@ axis Both Her İkisi @msg check an artboard to export dışa aktarılacak bir çalışma yüzeyi işaretleyin @msg check a layer to export dışa aktarılacak katmanı kontrol edin @msg select the art to export first önce dışa aktarılacak resmi seçin -@msg choice must be "merge" or "add", not `{choice}` seçim "{choice}" değil "birleştir" veya "ekle" olmalıdır +@msg choice must be "merge" or "add", not `{choice}` choice "merge" veya "add" olmalıdır, `{choice}` değil @msg choose a direction to divide in bölmek için bir yön seçin @msg choose a folder to export to dışa aktarılacak bir klasör seçin @msg choose a location for the package paket için bir yer seçin @@ -3961,16 +3991,16 @@ axis Both Her İkisi @msg compound paths can only contain paths bileşik yollar yalnızca yollar içerebilir @msg compressed (RLE) bitmaps can't be read yet sıkıştırılmış (RLE) bitmapler henüz okunamıyor @msg damaged hasarlı -@msg decimals must be {_1}–{_2} (got {_3}) ondalık sayılar {_1}–{_2} olmalıdır ({_3}'yi aldı) +@msg decimals must be {_1}–{_2} (got {_3}) decimals {_1}–{_2} olmalıdır ({_3}'yi aldı) @msg divide into 1 to {MAX_PIECES} slices 1 ila {MAX_PIECES} dilimlerine bölün -@msg effect {k} is `{_1}`, not `{id}` {k} efekti "{id}" değil "{_1}"dir +@msg effect {k} is `{_1}`, not `{id}` {k} efekti `{_1}`, `{id}` değil @msg empty brush stroke boş fırça darbesi @msg empty cutter boş kesici @msg empty path boş yol @msg file version {0} is newer than this VectorCraft supports ({VERSION}) dosya sürümü {0}, VectorCraft'ın desteklediğinden daha yenidir ({VERSION}) -@msg freeform point {i} needs `at` [x, y] and a valid `color` serbest biçimli nokta {i}'nin "[x, y]'de" ve geçerli bir "renk"te olması gerekir +@msg freeform point {i} needs `at` [x, y] and a valid `color` serbest biçimli nokta {i} için `at` [x, y] ve geçerli bir `color` gerekir @msg freeform line {i} must be an array of point indices Serbest biçimli çizgi {i}, bir nokta indeks dizisi olmalıdır -@msg give both `start` and `end` [x, y] (or neither) hem "başlangıç" hem de "son" [x, y] verin (ya da ikisini de) +@msg give both `start` and `end` [x, y] (or neither) hem `start` hem de `end` [x, y] verin (ya da hiçbirini) @msg give corners of the path: anchors where its sides meet at an angle yolun köşelerini verin: kenarlarının belli bir açıyla buluştuğu noktalara sabitleyin @msg give horizontal or vertical yatay veya dikey ver @msg give the path `id` and the width point `index` yol `id`sini ve genişlik noktası `index`i verin @@ -3978,8 +4008,8 @@ axis Both Her İkisi @msg image '{key}' has no pixels and was left out '{key}' resminde piksel yok ve dışarıda bırakıldı @msg image data `{_1}` is missing `{_1}` resim verisi eksik @msg image would be too large; lower the resolution resim çok büyük olur; çözünürlüğü düşür -@msg index must be a number or first, previous, next or last dizin bir sayı veya ilk, önceki, sonraki veya son olmalıdır -@msg index must be a number or first, previous, next or last, not `{other}` dizin bir sayı veya ilk, önceki, sonraki veya son olmalıdır, "{other}" değil +@msg index must be a number or first, previous, next or last index bir sayı ya da first, previous, next veya last olmalıdır +@msg index must be a number or first, previous, next or last, not `{other}` index bir sayı ya da first, previous, next veya last olmalıdır, `{other}` değil @msg installing from a path is not available on the web; pass the module as `dataBase64` bir yoldan kurulum web'de mümkün değildir; modülü `dataBase64` olarak iletin @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}`de dahili hata: {msg} (belge daha önce olduğu gibi bırakıldı; lütfen bu hatayı bildirin) @msg internal error: {msg} (please report this bug) dahili hata: {msg} (lütfen bu hatayı bildirin) @@ -3992,14 +4022,14 @@ axis Both Her İkisi @msg its Windows metafile preview was opened instead, which may simplify the art bunun yerine Windows meta dosyası önizlemesi açıldı, bu da işi basitleştirebilir @msg its preview was placed instead, at screen resolution bunun yerine önizlemesi ekran çözünürlüğüne yerleştirildi @msg join needs open paths katılmak için açık yollara ihtiyaç var -@msg lang must be one of {_1} dil {_1}'den biri olmalıdır +@msg lang must be one of {_1} lang şunlardan biri olmalıdır: {_1} @msg layers can't be wrap objects katmanlar sarma nesneleri olamaz @msg line {line}: value for `{var}` must be true or false satır {line}: `{var}` değeri true veya false olmalıdır @msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes satır {line}: `{var}` değeri {MAX_GRAPH_CSV} bayttan uzun -@msg missing `folder` 'klasör' eksik -@msg missing `id` (see plugin.list) `kimlik` eksik (bkz. eklenti.liste) -@msg missing `name` "isim" eksik -@msg missing `tool` 'araç' eksik +@msg missing `folder` `folder` eksik +@msg missing `id` (see plugin.list) `id` eksik (bkz. plugin.list) +@msg missing `name` `name` eksik +@msg missing `tool` `tool` eksik @msg missing id id eksik @msg missing index eksik dizin @msg New Window isn't available yet: a document opens in one window Yeni Pencere henüz kullanılamıyor: bir belge tek pencerede açılır @@ -4052,7 +4082,7 @@ axis Both Her İkisi @msg no library `{key}` (see {list}) `{key}` kitaplığı yok (bkz. {list}) @msg no native format yerel format yok @msg no pages sayfa yok -@msg no pattern `{name}` "{name}" modeli yok +@msg no pattern `{name}` `{name}` deseni yok @msg no perspective grid preset named `{name}` `{name}` adında perspektif ızgarası ön ayarı yok @msg no plug-in {0} is installed {0} eklentisi kurulu değil @msg no plug-in {id} is installed {id} eklentisi kurulu değil @@ -4103,7 +4133,7 @@ axis Both Her İkisi @msg no swatch `{name}` renk örneği yok `{name}` @msg no swatch library `{l}` (see swatch.library.list) renk örneği kitaplığı yok `{l}` (bkz. swatch.library.list) @msg no symbol instances selected sembol örneği seçilmedi -@msg no target object has an opacity mask: select one or give `id` hiçbir hedef nesnenin opaklık maskesi yok: birini seçin veya "id" verin +@msg no target object has an opacity mask: select one or give `id` hiçbir hedef nesnenin opaklık maskesi yok: birini seçin veya `id` verin @msg no threaded text selected dizili metin seçilmedi @msg no user library folder here burada kullanıcı kitaplığı klasörü yok @msg no user workspace `{from}` kullanıcı çalışma alanı yok `{from}` @@ -4111,7 +4141,7 @@ axis Both Her İkisi @msg node {_1} is on a locked artboard {_1} düğümü kilitli bir çalışma yüzeyinde @msg not a DXF drawing DXF çizimi değil @msg not a VectorCraft file: {0} bir VectorCraft dosyası değil: {0} -@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft kısayol seti değil ("geçersiz kılmalar" eksik) +@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft kısayol seti değil (`overrides` eksik) @msg not a graphic style library (format `{_1}`) grafik stili kitaplığı değil (`{_1}` biçimi) @msg not a shortcut set: the text doesn't start with a slash name and an opening brace bir kısayol seti değil: metin eğik çizgi adı ve açılış paranteziyle başlamıyor @msg not a stored central record saklanan merkezi bir kayıt değil @@ -4146,9 +4176,9 @@ axis Both Her İkisi @msg replace: the object to replace was changed or deleted değiştir: değiştirilecek nesne değiştirildi veya silindi @msg pattern missing desen eksik @msg patterns nested too deeply are filled with mid-grey çok derin iç içe geçmiş desenler orta gri renkle dolu -@msg percent must be above 0 and at most 6400, not {p} yüzde {p} değil, 0'ın üzerinde ve en fazla 6400 olmalıdır +@msg percent must be above 0 and at most 6400, not {p} percent 0'ın üzerinde ve en fazla 6400 olmalıdır, {p} değil @msg pick a colour, gradient or pattern to save as a swatch renk örneği olarak kaydetmek için bir renk, degrade veya desen seçin -@msg plane must be left, right or ground düzlem solda, sağda veya yerde olmalıdır +@msg plane must be left, right or ground plane left, right veya ground olmalıdır @msg plug-in ABI: {0} eklenti ABI: {0} @msg plug-in exceeded its {0} eklenti {0} sınırını aştı @msg plug-in failed: {0} eklenti başarısız oldu: {0} @@ -4167,8 +4197,8 @@ axis Both Her İkisi @msg result would exceed the canvas (coordinates out of range) sonuç tuvali aşacaktır (koordinatlar aralık dışındadır) @msg objects can't be nested more than 32 deep nesneler 32'den fazla derinliğe yerleştirilemez @msg the SVG's elements are nested more than {0} deep SVG'nin öğeleri {0} derinliğinden daha fazla yuvalanmıştır -@msg scale must be a number from {_1} to {_2}, not {_3} ölçek {_3} değil, {_1} ile {_2} arasında bir sayı olmalıdır -@msg scale must be a number from {_1} to {_2}, not {s} ölçek {s} değil, {_1} ile {_2} arasında bir sayı olmalıdır +@msg scale must be a number from {_1} to {_2}, not {_3} scale {_1} ile {_2} arasında bir sayı olmalıdır, {_3} değil +@msg scale must be a number from {_1} to {_2}, not {s} scale {_1} ile {_2} arasında bir sayı olmalıdır, {s} değil @msg select (or target) objects whose appearance isn't basic Görünümü temel olmayan nesneleri seçin (veya hedefleyin) @msg select a Live Paint group bir Canlı Boyama grubu seçin @msg select a blend bir karışım seç @@ -4193,7 +4223,7 @@ axis Both Her İkisi @msg select at least two objects en az iki nesne seç @msg exit isolation mode first önce izolasyon modundan çık @msg select objects, not layers katmanları değil nesneleri seçin -@msg select one path, or give its `id` bir yol seçin veya "kimliğini" verin +@msg select one path, or give its `id` bir yol seçin veya `id` değerini verin @msg select paths to join katılmak için yolları seçin @msg select the art to repeat tekrarlanacak resmi seçin @msg select the artwork to warp çarpıtılacak resmi seçin @@ -4204,10 +4234,10 @@ axis Both Her İkisi @msg shadings of an unknown type were left out bilinmeyen türdeki gölgeler dışarıda bırakıldı @msg shadings of more than 4096 patches or triangles were left out 4096'dan fazla yamanın veya üçgenin gölgeleri dışarıda bırakıldı @msg shadings whose functions are of an unknown type are filled with their middle colour İşlevi bilinmeyen tipteki gölgelikler orta renkle doldurulur -@msg shortcut for `{k}` must be a string "{k}" kısayolu bir dize olmalıdır -@msg stop {i} needs `offset` {i}'nin `ofset`e ihtiyacı var -@msg stop {i} needs a valid `color` stop {i}'nin geçerli bir "renk"e ihtiyacı var -@msg stop {i}: `{k}` must be a number {i}'yi durdur: `{k}` bir sayı olmalıdır +@msg shortcut for `{k}` must be a string `{k}` kısayolu bir dize olmalıdır +@msg stop {i} needs `offset` durak {i} için `offset` gerekir +@msg stop {i} needs a valid `color` durak {i} için geçerli bir `color` gerekir +@msg stop {i}: `{k}` must be a number durak {i}: `{k}` bir sayı olmalıdır @msg the EMF header is damaged EMF başlığı hasarlı @msg the PDF has no pages PDF'de sayfa yok @msg the PDF is no longer loaded PDF artık yüklenmiyor @@ -4286,7 +4316,7 @@ axis Both Her İkisi @msg unknown freeform mode `{m}` (points, lines) bilinmeyen serbest biçimli mod `{m}` (noktalar, çizgiler) @msg unknown gradient kind `{k}` (linear, radial, freeform) bilinmeyen gradyan türü `{k}` (doğrusal, radyal, serbest biçimli) @msg unknown highlight `{h}` bilinmeyen vurgu `{h}` -@msg unknown option `{path}.{k}` bilinmeyen seçenek '{path}.{k}' +@msg unknown option `{path}.{k}` bilinmeyen seçenek `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (koru, benzet veya at) @msg unknown panel `{raw}` bilinmeyen panel `{raw}` @msg unknown preference `{key}` bilinmeyen tercih `{key}` @@ -4358,6 +4388,7 @@ axis Both Her İkisi @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} görüntünün kodu çözülemedi: çarpı işaretli kutular olarak gösteriliyor @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} bağlantılı görsel diskte değişti: Bağlantıları Güncelle yeni sürümleri gösterir @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Diskte değişen {n} bağlantılı dosya güncellendi +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings belge, bu PDF/X dosyasının dışarıda bıraktığı bir taşma payı ayarlıyor: "Belge taşma payı ayarlarını kullan" seçeneğini açın @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} {n} notla dışa aktarıldı: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} yazı tipi dosyası artık {folder} içinde @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} yazı tipi dosyası artık {folder} içinde; {failed} eklenemedi: {reason} @@ -4381,12 +4412,12 @@ axis Both Her İkisi @msg The graphics were lost and couldn't be restarted. Grafikler kayboldu ve yeniden başlatılamadı. @msg Your unsaved changes are kept in this browser: reload the page to get them back. Kaydedilmemiş değişiklikleriniz bu tarayıcıda tutuluyor: onları geri almak için sayfayı yeniden yükleyin. @msg Reload the page to go on (changes not saved yet may be lost). Devam etmek için sayfayı yeniden yükleyin (henüz kaydedilmemiş değişiklikler kaybolabilir). -@msg tool (a tool id) is required araç (araç kimliği) gerekli +@msg tool (a tool id) is required tool (bir araç kimliği) gerekli @msg {tool} isn't in a toolbar group of several tools {tool} çeşitli araçlardan oluşan bir araç çubuğu grubunda değil -@msg onto must name a panel floating in another group üzerine başka bir grupta yüzen bir panel adı verilmelidir -@msg below and above must name a panel floating in another group alt ve üst, başka bir grupta yüzen bir panele ad vermelidir -@msg give only one of onto, below and above üstüne, altına ve üstüne yalnızca birini verin -@msg column must be true or a number from 1 sütun doğru veya 1'den bir sayı olmalıdır +@msg onto must name a panel floating in another group onto başka bir grupta yüzen bir paneli adlandırmalıdır +@msg below and above must name a panel floating in another group below ve above başka bir grupta yüzen bir paneli adlandırmalıdır +@msg give only one of onto, below and above onto, below ve above değerlerinden yalnızca birini verin +@msg column must be true or a number from 1 column true veya 1'den başlayan bir sayı olmalıdır @msg x and y must both be numbers x ve y'nin her ikisi de sayı olmalıdır @msg "{0}" is not a colour: write it as #rrggbb "{0}" bir renk değildir: #rrggbb olarak yazın @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead bu düz renkli bir logo değildir (düz alanların %{_1}'si birkaç düz renkle eşleşir), dolayısıyla Düz Logo modu degradelerini yamalara dönüştürür: bunun yerine Renk modunu kullanın @@ -4486,9 +4517,10 @@ axis Both Her İkisi Em Box Em Kutusu x-Height x-Yüksekliği ICF Box ICF Kutusu -@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve "id", yalnızca "run" ve "command_batch" çözümleyen {r} adım referansıdır -@msg `ids` must be an array of object ids, not {v} "kimlikler" {v} değil, bir nesne kimlikleri dizisi olmalıdır -@msg `corners` must be an array of anchor indices, not {v} "köşeler" {v} değil, bir bağlantı indeksleri dizisi olmalıdır +@msg `colors` must be an array of colors, not {v} `colors` {v} değil, bir renk dizisi olmalıdır +@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`, yalnızca `run` ve `command_batch` komutlarının çözümlediği {r} adım referansıdır +@msg `ids` must be an array of object ids, not {v} `ids` bir nesne kimlikleri dizisi olmalıdır, {v} değil +@msg `corners` must be an array of anchor indices, not {v} `corners` bir bağlantı noktası indeksleri dizisi olmalıdır, {v} değil @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: {i} adımının sonucunun böyle bir değeri yok (değişmez $ için $$ yazın) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: bundan önce {i} adımı yok (adımlar 0'dan itibaren sayılır; $$ yerine $ yazın) @msg object ids are non-negative integers, not {v} nesne kimlikleri {v} değil, negatif olmayan tam sayılardır @@ -4616,19 +4648,19 @@ effect Soft Yumuşak Resize panels Panelleri yeniden boyutlandır Resize panel window Panel penceresini yeniden boyutlandır @msg Invalid panel layout; restored the default dock Geçersiz panel düzeni; varsayılan dock'u geri yükledim -@msg action is required eylem gerekli -@msg anchor must name a registered panel çapa kayıtlı bir panele isim vermelidir -@msg before must name a panel önce bir panele isim vermeliyim +@msg action is required action gerekli +@msg anchor must name a registered panel anchor kayıtlı bir paneli adlandırmalıdır +@msg before must name a panel before bir paneli adlandırmalıdır @msg docking action contains an unknown panel or an excessive split path yerleştirme eylemi bilinmeyen bir panel veya aşırı bölünmüş yol içeriyor -@msg group must be true or false grup doğru veya yanlış olmalıdır -@msg move requires a destination panel taşıma bir hedef paneli gerektirir -@msg move requires an existing destination panel taşıma mevcut bir hedef paneli gerektirir -@msg onto must name a floating panel üzerine kayan bir panel adı verilmelidir +@msg group must be true or false group true veya false olmalıdır +@msg move requires a destination panel move bir hedef paneli gerektirir +@msg move requires an existing destination panel move mevcut bir hedef paneli gerektirir +@msg onto must name a floating panel onto yüzen bir paneli adlandırmalıdır @msg panel group is empty panel grubu boş -@msg panel must name a registered panel panel kayıtlı bir panele isim vermelidir +@msg panel must name a registered panel panel kayıtlı bir paneli adlandırmalıdır @msg saved panel layout is invalid; reset the workspace first kayıtlı panel düzeni geçersiz; önce çalışma alanını sıfırlayın -@msg zone must be a string bölge bir dize olmalıdır -@msg zone must be tab, center, left, right, top or bottom bölge sekme, orta, sol, sağ, üst veya alt olmalıdır +@msg zone must be a string zone bir dize olmalıdır +@msg zone must be tab, center, left, right, top or bottom zone tab, center, left, right, top veya bottom olmalıdır @msg {key} must be a finite coordinate {key} sonlu bir koordinat olmalıdır @msg x and y must be provided together x ve y birlikte sağlanmalıdır @msg invalid restored panel group geçersiz geri yüklenen panel grubu @@ -5181,7 +5213,7 @@ theme Appearance Görünüm Screen Mode Controls Ekran Modu Kontrolleri Drawer menu Çekmece menüsü Toolbar: Show Controls Araç Çubuğu: Kontrolleri Göster -@msg control must be fillStroke, coloring, drawMode or screenMode kontrol fillStroke, colouring, DrawMode veya screenMode olmalıdır +@msg control must be fillStroke, coloring, drawMode or screenMode control fillStroke, coloring, drawMode veya screenMode olmalıdır @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode bilinmeyen kontrol `{name}`: fillStroke, colouring,drawMode veya screenMode @msg give a fraction `t` from 0 to 1 0'dan 1'e kadar bir `t` kesri verin @msg give the path `id` `id` yolunu ver @@ -5240,11 +5272,11 @@ theme Appearance Görünüm @msg Unknown dimension style Bilinmeyen boyut stili @msg Unknown unit Bilinmeyen birim Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Açık profillerin dairesel uçlarını kapatın. Kısmi süpürmeler kesik taraflarını açık tutar. -@msg span must be "object" or "selection", got {v} yayılma "nesne" veya "seçim" olmalıdır, {v} var -@msg span: "selection" places a `gradient`, not a swatch yayılma: "seçim" bir renk örneği değil, bir "degrade" yerleştirir -@msg span: "selection" places a gradient: give `gradient` span: "selection" bir degrade yerleştirir: "gradient" verir -@msg span: "selection" places a linear or radial gradient, not a freeform one yayılma: "seçim" serbest biçimli bir degrade değil, doğrusal veya radyal bir degrade yerleştirir -@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" degradeyi kendisi yerleştirir: "start" ve "end"i dışarıda bırakın (bunu zaten belge koordinatlarına yerleştirdiler) +@msg span must be "object" or "selection", got {v} span "object" veya "selection" olmalıdır, {v} değil +@msg span: "selection" places a `gradient`, not a swatch span: "selection" bir renk örneği değil, bir `gradient` yerleştirir +@msg span: "selection" places a gradient: give `gradient` span: "selection" bir degrade yerleştirir: `gradient` verin +@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" serbest biçimli bir degrade değil, doğrusal veya radyal bir degrade yerleştirir +@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" degradenin kendisini yerleştirir: `start` ve `end` değerlerini vermeyin (bunlar degradeyi zaten belge koordinatlarına yerleştirir) Make Image Dynamic Görüntüyü Dinamik Hale Getirin Import Data Sets… Veri Kümelerini İçe Aktar… Export Data Sets… Veri Kümelerini Dışa Aktarma… @@ -5262,8 +5294,8 @@ theme Appearance Görünüm @msg the action has no steps eylemin hiçbir adımı yok @msg the action has no included steps eylemin dahil edilmiş adımı yok @msg no such action step öyle bir eylem adımı yok -@msg included must be true or false "included" true veya false olmalıdır -@msg others must be true or false "others" true veya false olmalıdır +@msg included must be true or false included true veya false olmalıdır +@msg others must be true or false others true veya false olmalıdır Accent Color Vurgu Rengi Amber kehribar Coral Mercan @@ -5416,6 +5448,7 @@ theme Appearance Görünüm The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Kayıt panelindeki ad “{typed}” idi; VectorCraft dosya türünün uzantısını ekledi. Dosyayı değiştirmek içeriğinin üzerine yazar. Thread Text Metni Bağla Trackpad Gesture to Rotate View Görünümü Döndürmek İçin İzleme Dörtgeni Hareketi + Hold a Tool Shortcut to Use the Tool Temporarily Aracı Geçici Olarak Kullanmak İçin Kısayolunu Basılı Tutma Typing Yazma Uniformly Scaled Tekdüze Ölçeklenmiş Unlock Variables Değişkenlerin Kilidini Aç @@ -5432,7 +5465,7 @@ theme Appearance Görünüm @msg a spine keeps at least two points bir omurga en az iki nokta barındırır @msg cancelled; nothing was changed iptal edildi; hiçbir şey değiştirilmedi @msg handle must be in or out handle in veya out olmalıdır -@msg index must be ≥ 0 dizin ≥ 0 olmalıdır +@msg index must be ≥ 0 index ≥ 0 olmalıdır @msg library panel `{given}` isn't open `{given}` kütüphane paneli açık değil @msg mask art ends outside a mask dictionary maske çizimi bir maske sözlüğünün dışında bitiyor @msg missing number `{key}` `{key}` sayısı eksik @@ -5457,6 +5490,7 @@ theme Appearance Görünüm @msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page düzenleme verileri {w} × {h} pt boyutunda bir sayfa içindir, dosyanın {pw} × {ph} pt sayfası için değil @msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} düzenleme verileri {w} × {h} pt boyutunda bir sayfa içindir, dosyanın {scale} ölçeğindeki {pw} × {ph} pt sayfası @msg no history to clear temizlenecek geçmiş yok +@msg no history state {v} {v} geçmiş durumu yok @msg only http, https and mailto URLs open in the browser, not “{url}” tarayıcıda yalnızca http, https ve mailto URL'leri açılır, “{url}” açılmaz @msg {_1} files are written by file.saveAsTemplate {_1} dosyaları file.saveAsTemplate tarafından yazılır @msg {id} opens another app: only the person at the keyboard can run it {id} başka bir uygulamayı açar: yalnızca klavye başındaki kişi çalıştırabilir @@ -5726,7 +5760,7 @@ axis Y Axis Y ekseni Fine noise hides banding in the gradient fill İnce gürültü, degrade dolgudaki bantları gizler @msg `dither` applies to gradient fills (stroke: false) `dither` degrade dolgulara uygulanır (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` degrade dolgulara uygulanır (paint.setFill) -@msg `dither` must be true or false "dither" doğru veya yanlış olmalıdır +@msg `dither` must be true or false `dither` true veya false olmalıdır Snapping Yaslama Snap to Tangent Teğete Yasla Snap to Perpendicular Dikeye Yasla @@ -5745,6 +5779,33 @@ axis Y Axis Y ekseni @msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz @msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? + ID: {id} Kimlik: {id} + Version: {version} Sürüm: {version} + File SHA-256: {hash} Dosya SHA-256: {hash} + Read the objects it is given Kendisine verilen nesneleri okuma + Change, add and delete objects in the document Belgedeki nesneleri değiştirme, ekleme ve silme + Keep data of its own with the document Kendi verilerini belgeyle birlikte saklama + Change how the objects its effect is applied to are drawn Efektinin uygulandığı nesnelerin nasıl çizildiğini değiştirme + Write the document as a file in its own format Belgeyi kendi biçiminde bir dosya olarak yazma + Show its progress, see Cancel and write log lines İlerlemesini gösterme, iptali algılama ve günlük satırları yazma +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modül `env.{name}` öğesini ana bilgisayarınkinden farklı bir türle içe aktarıyor: ana bilgisayarın türü {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `progress` izniyle kullanabilir +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca `env.vc_progress`, `env.vc_cancelled` ve `env.vc_log` öğelerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` değeri {_1}; bu ana bilgisayar 1 ile {API_LEVEL} arasındaki eklenti API düzeylerini destekler +@msg `grant` must be a list of permission names `grant` izin adlarından oluşan bir liste olmalıdır +@msg `grant` names an unknown permission `{shown}` `grant` bilinmeyen bir izin adlandırıyor: `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` türündeki bir eklentinin `{_2}` iznine ihtiyacı vardır +@msg at most {MAX_PERMISSIONS} `permissions` en fazla {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` `{shown}` izni `{_1}` türündeki bir eklentiye uygulanmaz +@msg permission `{shown}` is listed twice `{shown}` izni iki kez listelenmiş +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez +@msg unknown permission `{shown}` bilinmeyen izin `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi +@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 # Actions panel: action sets (#3712). New Set… Yeni Set… New Set Yeni Set @@ -5753,6 +5814,19 @@ axis Y Axis Y ekseni Action Options… Eylem Seçenekleri… Action Options Eylem Seçenekleri Playback Options… Oynatma Seçenekleri… + Insert Stop… Durak Ekle… + Insert Stop Durak Ekle + Record Stop Durak Kaydet + Allow Continue Devam Etmeye İzin Ver + Continue Devam Et + Continue Action Eyleme Devam Et + Delete Action Step Eylem Adımını Sil + Press Play Current Selection in the Actions panel to go on. Devam etmek için Eylemler panelinde Geçerli Seçimi Oynat'a basın. + Stop: {message} Durak: {message} +@msg no action is waiting at a stop durakta bekleyen eylem yok +@msg message must be text "message" metin olmalıdır +@msg allowContinue must be true or false "allowContinue" true veya false olmalıdır +@msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz Playback Options Oynatma Seçenekleri Accelerated Hızlandırılmış Step by Step Adım Adım @@ -5931,8 +6005,8 @@ anchor Points: Noktalar: Hue/Saturation Ton/Doygunluk Temperature/Tint Sıcaklık/Renk Tonu @msg `{id}` is not a colour adjustment `{id}` bir renk ayarı değil -@msg missing `effect` "isim" eksik -@msg `params` must be an object "gradyan" bir nesne olmalıdır +@msg missing `effect` `effect` eksik +@msg `params` must be an object `params` bir nesne olmalıdır # Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages. Path Precision Tools Yol Hassasiyet Araçları Anchor Eraser Brush Bağlantı Noktası Silgi Fırçası @@ -6191,6 +6265,7 @@ breakText Glyphs Glifler Gap 5 Boşluk 5 @msg {_1}: they came in as the page's art {_1}: sayfanın çizimi olarak içe aktarıldılar +@msg warped text objects that came in as drawn art, not as editable type: {_1} düzenlenebilir metin yerine çizim olarak içe aktarılan Çözgü uygulanmış metin nesneleri: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} dosyanın kendi sayfasından farklı çizilen nesneler sayfanın çizimi olarak içe aktarıldı: {_1} @msg You must choose a side pattern. Bir yan desen seçmelisiniz. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bbfc332f9..1b96c235e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -78,6 +78,11 @@ 3 Color Job… Завдання з 3 кольорами… 30° Angles Кути 30° 3D and Materials 3D та матеріали + Plane Площина + Extrude Видавлювання + Revolve Обертання + Inflate Надування + Select objects, then pick an object type to give them a 3D effect. Виділіть об’єкти, потім виберіть тип об’єкта, щоб надати їм 3D-ефект. 3D Ribbon 3D-стрічка 45° Angles Кути 45° 60° Angles Кути 60° @@ -278,6 +283,8 @@ font-technology Variable Варіативні Show Cross Hairs Показати перехрестя Show Video Safe Areas Показати безпечні зони відео Video Ruler Pixel Aspect Ratio: Співвідношення сторін пікселя відео-лінійок: + Fade Region Outside Artboard Затемнювати ділянку поза монтажною областю + Update While Dragging Оновлювати під час перетягування Hide Video Rulers Приховати відео-лінійки Background Contents Вміст фону Background Contents: Вміст фону: @@ -1084,6 +1091,8 @@ font-technology Variable Варіативні Export for Screens Settings Параметри експорту для екранів Export for Screens… Експортувати для екранів… Export Keyboard Shortcuts… Експортувати сполучення клавіш… + Export Keyboard Shortcuts as Text… Експортувати сполучення клавіш як текст… + Export Text… Експорт тексту… Export Option Параметр експорту Export PDF Експортувати PDF Export PDF Presets Експортувати набори PDF @@ -1382,6 +1391,8 @@ file-filter Templates Шаблони Grays Сірі Grays and Neutrals Сірі та нейтральні Grayscale Відтінки сірого + High Color Багато кольорів + Low Color Мало кольорів Grayscale images Зображення у відтінках сірого Greek & Cyrillic Грецька та кирилиця Green Зелений @@ -1455,6 +1466,7 @@ file-filter Templates Шаблони Step Backward Крок назад Step Forward Крок вперед Clear History Очистити історію + New Document from State Новий документ зі стану Set History Limit… Задати межу історії… History States Стан історії # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1849,6 +1861,9 @@ file-filter Templates Шаблони Live Paint Selection Tool Інструмент «Виділення швидкої заливки» Live Shape Properties Властивості динамічної фігури Load Color Profile… Завантажити профіль кольору… + Load Character Styles… Завантажити стилі символів… + Load Paragraph Styles… Завантажити стилі абзаців… + Load All Styles… Завантажити всі стилі… Remove Color Profile… Видалити профіль кольору… Load Image into Clipboard Завантажити зображення в буфер обміну Load Metafile into Clipboard Завантажити метафайл у буфер обміну @@ -2718,6 +2733,14 @@ digits Malayalam Малаялам Relink Відновити зв’язок Relink Linked Files to the Document Відновити зв’язані файли з документом Relink to Folder… Відновити зв’язок із папкою… + Each Link Separately Кожен зв’язок окремо + Selected Instances Вибрані екземпляри + All Instances Усі екземпляри + Missing Links from This Folder Відсутні зв’язки з цієї папки +@msg scope {v} is not each|selected|all|missingInFolder scope {v} не є одним із each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each і paths задаються разом: paths перелічує записи з ids і path замість ids, path і folder +@msg paths entry {i} needs path and ids (object ids no other entry has) запису {i} у paths потрібні path і ids (ідентифікатори об’єктів, яких немає в інших записах) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder приймає path: у папці цього файлу шукаються інші відсутні зв’язки Relink… Відновити зв’язок… Find Linked Files Знайти пов’язані файли Find Linked Files in Folder… Знайти пов’язані файли в папці… @@ -2762,6 +2785,7 @@ digits Malayalam Малаялам Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замініть його іншим файлом або ігноруйте: зображення продовжать показувати попередній перегляд, збережений з документом. Replace Spine Замінити вісь Replace Symbol Замінити символ + Reset Transformation Скинути трансформацію Replace With Font Замінити шрифтом Replace With Font From Замінити шрифтом з System Система @@ -2778,6 +2802,8 @@ digits Malayalam Малаялам Reset OpenType Features Скинути функції OpenType Reset Panel Скинути панель Reset Preferences Скинути налаштування + Search Preferences Пошук у налаштуваннях + No settings found Налаштування не знайдено Reset Profiles Скинути профілі Rotate View Повернути вид Reset Rotate View Скинути поворот виду @@ -2833,6 +2859,7 @@ digits Malayalam Малаялам Rotate Tool Інструмент «Поворот» Rotate View Tool Інструмент «Поворот виду» Trackpad Gesture to Rotate View Повертати вид жестом трекпада + Hold a Tool Shortcut to Use the Tool Temporarily Тимчасово використовувати інструмент, утримуючи його клавішу Rotate… Повернути… Rotation Поворот Roughen… Надати нерівності… @@ -3275,6 +3302,7 @@ effect Points Точки Spaces Пробіли Spacing Guides Напрямні інтервалів Invoke Distance Guides Викликати напрямні відстаней + Snap to Last Location Прив’язати до останнього розташування Spacing: Інтервал: Spanish Іспанська Specified Distance Задана відстань @@ -3960,6 +3988,9 @@ axis Both Обидві Put back in the toolbar Повернути на панель інструментів Float Panel Від’єднати панель Dock Panel Прикріпити панель + Dock to Top Прикріпити вгорі + Dock to Bottom Прикріпити внизу + Dock Control Bar Прикріпити панель керування Drag to float the toolbar Перетягніть, щоб від’єднати панель інструментів Drag to the window's left edge to dock Перетягніть до лівого краю вікна для прикріплення Float Tool Group Від’єднати групу інструментів @@ -4402,6 +4433,7 @@ axis Both Обидві @msg nothing to save: the document has no visible art нічого зберігати: у документі немає видимого малюнка @msg nothing to undo нічого скасовувати @msg no history to clear немає історії для очищення +@msg no history state {v} немає стану історії {v} @msg nothing to warp нічого деформувати @msg nothing to wrap нічого обтікати @msg object {0} cannot have children об’єкт {0} не може мати нащадків @@ -4650,6 +4682,7 @@ axis Both Обидві @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes не вдалося декодувати {n} зображення: воно показане як перекреслена рамка|не вдалося декодувати {n} зображення: вони показані як перекреслені рамки|не вдалося декодувати {n} зображень: вони показані як перекреслені рамки @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} пов’язане зображення змінилося на диску: «Оновити зв’язки» покаже нову версію|{n} пов’язані зображення змінилися на диску: «Оновити зв’язки» покаже нові версії|{n} пов’язаних зображень змінилися на диску: «Оновити зв’язки» покаже нові версії @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Оновлено {n} пов’язаний файл, змінений на диску|Оновлено {n} пов’язані файли, змінені на диску|Оновлено {n} пов’язаних файлів, змінених на диску +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings у документі задано виліт, який цей файл PDF/X не містить: увімкніть «Використовувати параметри вильоту документа» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Експортовано: {what} з {n} приміткою: {first}|Експортовано: {what} з {n} примітками: {first}|Експортовано: {what} з {n} примітками: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} файл шрифту тепер у {folder}|{n} файли шрифтів тепер у {folder}|{n} файлів шрифтів тепер у {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} файл шрифту тепер у {folder}; не вдалося додати {failed}: {reason}|{n} файли шрифтів тепер у {folder}; не вдалося додати {failed}: {reason}|{n} файлів шрифтів тепер у {folder}; не вдалося додати {failed}: {reason} @@ -4762,6 +4795,7 @@ axis Both Обидві Exit Вийти @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — це посилання на крок {r}, яке розв’язують лише `run` і `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` має бути масивом ідентифікаторів об’єктів, а не {v} +@msg `colors` must be an array of colors, not {v} `colors` має бути масивом кольорів, а не {v} @msg `corners` must be an array of anchor indices, not {v} `corners` має бути масивом індексів опорних точок, а не {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$) @@ -5384,6 +5418,33 @@ axis Y Axis Вісь Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи? + ID: {id} ID: {id} + Version: {version} Версія: {version} + File SHA-256: {hash} SHA-256 файлу: {hash} + Read the objects it is given Читати передані йому об’єкти + Change, add and delete objects in the document Змінювати, додавати й видаляти об’єкти в документі + Keep data of its own with the document Зберігати власні дані в документі + Change how the objects its effect is applied to are drawn Змінювати те, як малюються об’єкти, до яких застосовано його ефект + Write the document as a file in its own format Записувати документ у файл власного формату + Show its progress, see Cancel and write log lines Показувати свій поступ, помічати скасування і записувати рядки журналу +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `progress` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль імпортує `env.{name}` з іншим типом, ніж у хоста: у хоста тип {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише `env.vc_progress`, `env.vc_cancelled` і `env.vc_log` (рівень API модулів 2), без WASI, файлів і мережі +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` дорівнює {_1}; цей хост підтримує рівні API модулів від 1 до {API_LEVEL} +@msg `grant` must be a list of permission names `grant` має бути списком назв дозволів +@msg `grant` names an unknown permission `{shown}` `grant` називає невідомий дозвіл `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю виду `{_1}` потрібен дозвіл `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` щонайбільше {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` дозвіл `{shown}` не стосується модуля виду `{_1}` +@msg permission `{shown}` is listed twice дозвіл `{shown}` указано двічі +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає +@msg unknown permission `{shown}` невідомий дозвіл `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено # Actions panel: action sets (#3712). New Set… Новий набір… New Set Новий набір @@ -5392,6 +5453,19 @@ axis Y Axis Вісь Y Action Options… Параметри дії… Action Options Параметри дії Playback Options… Параметри відтворення… + Insert Stop… Вставити зупинку… + Insert Stop Вставити зупинку + Record Stop Записати зупинку + Allow Continue Дозволити продовження + Continue Продовжити + Continue Action Продовжити дію + Delete Action Step Видалити крок дії + Press Play Current Selection in the Actions panel to go on. Щоб продовжити, натисніть «Відтворити поточне виділення» на панелі «Дії». + Stop: {message} Зупинка: {message} +@msg no action is waiting at a stop жодна дія не очікує на зупинці +@msg message must be text message має бути текстом +@msg allowContinue must be true or false allowContinue має бути true або false +@msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою Playback Options Параметри відтворення Accelerated Прискорено Step by Step Покроково @@ -5834,6 +5908,7 @@ breakText Glyphs Гліфи Gap 5 Проміжок 5 @msg {_1}: they came in as the page's art {_1}: їх імпортовано як графіку сторінки +@msg warped text objects that came in as drawn art, not as editable type: {_1} деформовані текстові об’єкти, імпортовані як графіка, а не як редагований текст: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} об’єкти, що малювалися інакше, ніж власна сторінка файлу, імпортовано як графіку сторінки: {_1} @msg You must choose a side pattern. Виберіть бічний візерунок. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 47915fb17..ce98bc978 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -83,6 +83,11 @@ 3 x 3 Average Trung bình 3 x 3 30° Angles Góc 30° 3D and Materials 3D và vật liệu + Plane Mặt phẳng + Extrude Kéo dài + Revolve Xoay tròn + Inflate Phình + Select objects, then pick an object type to give them a 3D effect. Chọn đối tượng, rồi chọn một kiểu đối tượng để tạo hiệu ứng 3D cho chúng. 3D Ribbon Dải 3D 45° Angles Góc 45° 5 x 5 Average Trung bình 5 x 5 @@ -307,6 +312,8 @@ font-technology Variable Biến thiên Show Cross Hairs Hiện đường chữ thập Show Video Safe Areas Hiện vùng an toàn video Video Ruler Pixel Aspect Ratio: Tỷ lệ khung điểm ảnh của thước video: + Fade Region Outside Artboard Làm tối vùng bên ngoài bảng vẽ + Update While Dragging Cập nhật khi kéo Hide Video Rulers Ẩn thước video Background Contents Nội dung nền Background Contents: Nội dung nền: @@ -963,6 +970,9 @@ font-technology Variable Biến thiên Do Not Move Không di chuyển Do you want to save the changes you made to “{name}”? Bạn có muốn lưu các thay đổi đã thực hiện với “{name}”? Dock Panel Neo bảng + Dock to Top Neo lên trên + Dock to Bottom Neo xuống dưới + Dock Control Bar Neo thanh điều khiển Document Tài liệu Document Color Mode Chế độ màu tài liệu Document Info Thông tin tài liệu @@ -1244,6 +1254,8 @@ font-technology Variable Biến thiên Export for Screens Settings Cài đặt xuất cho màn hình Export for Screens… Xuất cho màn hình… Export Keyboard Shortcuts… Xuất phím tắt… + Export Keyboard Shortcuts as Text… Xuất phím tắt dưới dạng văn bản… + Export Text… Xuất văn bản… Export Library Xuất thư viện Export Library… Xuất thư viện… Export Option Tùy chọn xuất @@ -1592,6 +1604,8 @@ file-filter Templates Mẫu Grays Các màu xám Grays and Neutrals Xám và trung tính Grayscale Thang xám + High Color Nhiều màu + Low Color Ít màu Grayscale images Ảnh thang xám Greek & Cyrillic Hy Lạp & Cyrillic Green Xanh lá @@ -1674,6 +1688,7 @@ digits Hindi Hindi Step Backward Lùi một bước Step Forward Tiến một bước Clear History Xóa lịch sử + New Document from State Tài liệu mới từ trạng thái Set History Limit… Đặt giới hạn lịch sử… History States Trạng thái lịch sử # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2126,6 +2141,9 @@ digits Hindi Hindi Live Paint Selection Tool Công cụ Chọn Tô trực tiếp Live Shape Properties Thuộc tính hình dạng trực tiếp Load Color Profile… Tải hồ sơ màu… + Load Character Styles… Tải kiểu ký tự… + Load Paragraph Styles… Tải kiểu đoạn văn… + Load All Styles… Tải tất cả kiểu… Remove Color Profile… Xóa hồ sơ màu… Load Image into Clipboard Tải ảnh vào bảng tạm Load Metafile into Clipboard Tải metafile vào bảng tạm @@ -2989,6 +3007,14 @@ mark-style Japanese Kiểu Nhật Relink Liên kết lại Relink Linked Files to the Document Liên kết lại các tệp liên kết với tài liệu Relink to Folder… Liên kết lại vào thư mục… + Each Link Separately Từng liên kết riêng + Selected Instances Các phiên bản đã chọn + All Instances Tất cả phiên bản + Missing Links from This Folder Liên kết thiếu từ thư mục này +@msg scope {v} is not each|selected|all|missingInFolder scope {v} không phải each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each và paths đi cùng nhau: paths liệt kê các mục có ids và path, thay cho ids, path và folder +@msg paths entry {i} needs path and ids (object ids no other entry has) mục {i} của paths cần path và ids (ID đối tượng mà không mục nào khác có) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder nhận path: các liên kết thiếu khác được tìm trong thư mục của tệp đó Relink… Liên kết lại… Reload Plug-ins Tải lại plug-in Remove Gỡ @@ -3033,6 +3059,7 @@ mark-style Japanese Kiểu Nhật 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 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ữ Replace With Font From Thay thế bằng phông chữ từ System Hệ thống @@ -3055,6 +3082,8 @@ mark-style Japanese Kiểu Nhật Reset OpenType Features Đặt lại tính năng OpenType Reset Panel Đặt lại bảng Reset Preferences Đặt lại tùy chọn + Search Preferences Tìm trong tùy chọn + No settings found Không tìm thấy cài đặt Reset Profiles Đặt lại hồ sơ màu Rotate View Xoay chế độ xem Reset Rotate View Đặt lại xoay chế độ xem @@ -3126,6 +3155,7 @@ mark-style Japanese Kiểu Nhật Rotate Tool Công cụ Xoay Rotate View Tool Công cụ Xoay chế độ xem Trackpad Gesture to Rotate View Xoay chế độ xem bằng cử chỉ trên bàn di chuột + Hold a Tool Shortcut to Use the Tool Temporarily Giữ phím tắt của công cụ để tạm thời dùng công cụ đó Rotate… Xoay… Rotation Góc xoay Rotation relative to: Xoay tương đối với: @@ -3609,6 +3639,7 @@ mark-style Japanese Kiểu Nhật Spacing Khoảng cách Spacing Guides Đường căn khoảng cách Invoke Distance Guides Gọi đường căn đo khoảng cách + Snap to Last Location Hút vào vị trí cuối cùng Spacing: Khoảng cách: Spanish Tiếng Tây Ban Nha Spatter Bắn tung tóe @@ -4233,6 +4264,7 @@ theme Appearance Giao diện @msg `groupColors` must be an array `groupColors` phải là một mảng @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` là tham chiếu bước {r}, chỉ `run` và `command_batch` giải quyết @msg `ids` must be an array of object ids, not {v} `ids` phải là mảng id đối tượng, không phải {v} +@msg `colors` must be an array of colors, not {v} `colors` phải là mảng màu, không phải {v} @msg `library` is a library id or name (see {list}) `library` là id hoặc tên thư viện (xem {list}) @msg `mode` applies to freeform gradients `mode` áp dụng cho chuyển sắc tự do @msg `preserve` must be an object `preserve` phải là một đối tượng @@ -4710,6 +4742,7 @@ theme Appearance Giao diện @msg nothing to save: the document has no visible art không có gì để lưu: tài liệu không có tác phẩm hiển thị @msg nothing to undo không có gì để hoàn tác @msg no history to clear không có lịch sử để xóa +@msg no history state {v} không có trạng thái lịch sử {v} @msg nothing to warp không có gì để biến dạng @msg nothing to wrap không có gì để bọc @msg object ids are non-negative integers, not {v} id đối tượng là số nguyên không âm, không phải {v} @@ -4779,6 +4812,7 @@ theme Appearance Giao diện @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} hình ảnh không thể giải mã: chúng hiển thị dưới dạng khung có dấu gạch chéo @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} ảnh liên kết đã thay đổi trên đĩa: Cập nhật liên kết hiển thị phiên bản mới @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Đã cập nhật {n} tệp liên kết đã thay đổi trên đĩa +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings tài liệu đặt bleed mà tệp PDF/X này bỏ qua: hãy bật "Dùng cài đặt bleed của tài liệu" @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Đã xuất {what} với {n} ghi chú: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} tệp phông chữ hiện ở trong {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} tệp phông chữ hiện ở trong {folder}; {failed} không thể thêm: {reason} @@ -5385,6 +5419,33 @@ axis Y Axis Trục Y @msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy` @msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này? + ID: {id} ID: {id} + Version: {version} Phiên bản: {version} + 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ó + Change, add and delete objects in the document Thay đổi, thêm và xóa đối tượng trong tài liệu + Keep data of its own with the document Lưu dữ liệu riêng cùng tài liệu + Change how the objects its effect is applied to are drawn Thay đổi cách vẽ các đối tượng được áp dụng hiệu ứng của nó + Write the document as a file in its own format Ghi tài liệu thành tệp theo định dạng riêng + Show its progress, see Cancel and write log lines Hiển thị tiến độ, nhận biết yêu cầu hủy và ghi các dòng nhật ký +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `progress` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} mô-đun nhập `env.{name}` với kiểu khác với kiểu của máy chủ: kiểu của máy chủ là {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập `env.vc_progress`, `env.vc_cancelled` và `env.vc_log` (API plug-in cấp 2), không có WASI, tệp hay mạng +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` là {_1}; máy chủ này hỗ trợ các mức API plug-in từ 1 đến {API_LEVEL} +@msg `grant` must be a list of permission names `grant` phải là danh sách tên quyền +@msg `grant` names an unknown permission `{shown}` `grant` nêu một quyền không xác định `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` plug-in loại `{_1}` cần quyền `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` tối đa {MAX_PERMISSIONS} `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` quyền `{shown}` không áp dụng cho plug-in loại `{_1}` +@msg permission `{shown}` is listed twice quyền `{shown}` được liệt kê hai lần +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó +@msg unknown permission `{shown}` quyền không xác định `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi +@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 # Actions panel: action sets (#3712). New Set… Bộ mới… New Set Bộ mới @@ -5393,6 +5454,19 @@ axis Y Axis Trục Y Action Options… Tùy chọn hành động… Action Options Tùy chọn hành động Playback Options… Tùy chọn phát lại… + Insert Stop… Chèn điểm dừng… + Insert Stop Chèn điểm dừng + Record Stop Ghi điểm dừng + Allow Continue Cho phép tiếp tục + Continue Tiếp tục + Continue Action Tiếp tục hành động + Delete Action Step Xóa bước hành động + Press Play Current Selection in the Actions panel to go on. Để tiếp tục, nhấn Phát vùng chọn hiện tại trong bảng Hành động. + Stop: {message} Điểm dừng: {message} +@msg no action is waiting at a stop không có hành động nào đang chờ ở điểm dừng +@msg message must be text message phải là văn bản +@msg allowContinue must be true or false allowContinue phải là true hoặc false +@msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng Playback Options Tùy chọn phát lại Accelerated Tăng tốc Step by Step Từng bước @@ -5835,6 +5909,7 @@ breakText Glyphs Glyph Gap 5 Khoảng hở 5 @msg {_1}: they came in as the page's art {_1}: đã được nhập dưới dạng hình vẽ của trang +@msg warped text objects that came in as drawn art, not as editable type: {_1} các đối tượng văn bản biến dạng được nhập dưới dạng hình vẽ, không phải văn bản có thể chỉnh sửa: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} các đối tượng được vẽ khác với trang riêng của tệp đã được nhập dưới dạng hình vẽ của trang: {_1} @msg You must choose a side pattern. Bạn phải chọn mẫu bên. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e646c956b..a2cf40630 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -26,6 +26,11 @@ 3 Color Job… 3 色作业… 30° Angles 30° 角 3D and Materials 3D 与材质 + Plane 平面 + Extrude 突出 + Revolve 回转 + Inflate 膨胀 + Select objects, then pick an object type to give them a 3D effect. 选择对象,然后选取一种对象类型,为其添加 3D 效果。 3D Ribbon 3D 缎带 45° Angles 45° 角 60° Angles 60° 角 @@ -230,6 +235,8 @@ Show Cross Hairs 显示十字线 Show Video Safe Areas 显示视频安全区域 Video Ruler Pixel Aspect Ratio: 视频标尺像素长宽比: + Fade Region Outside Artboard 调暗画板外的区域 + Update While Dragging 拖动时更新 Hide Video Rulers 隐藏视频标尺 Background Contents 背景内容 Background Contents: 背景内容: @@ -1030,6 +1037,8 @@ Export for Screens Settings 导出为屏幕所用格式设置 Export for Screens… 导出为屏幕所用格式… Export Keyboard Shortcuts… 导出键盘快捷键… + Export Keyboard Shortcuts as Text… 将键盘快捷键导出为文本… + Export Text… 导出文本… Export Option 导出选项 Export PDF 导出 PDF Export PDF Presets 导出 PDF 预设 @@ -1324,6 +1333,8 @@ file-filter Templates 模板 Grays 灰度级数 Grays and Neutrals 灰色和中性色 Grayscale 灰度 + High Color 多色 + Low Color 少色 Flat Logo 扁平徽标 Grayscale images 灰度图像 Greek & Cyrillic 希腊文与西里尔文 @@ -1394,6 +1405,7 @@ file-filter Templates 模板 Step Backward 后退一步 Step Forward 前进一步 Clear History 清除历史记录 + New Document from State 从状态新建文档 Set History Limit… 设置历史记录上限… History States 历史记录步数 # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1737,6 +1749,9 @@ file-filter Templates 模板 Live Paint Selection Tool 实时上色选择工具 Live Shape Properties 实时形状属性 Load Color Profile… 载入颜色配置文件… + Load Character Styles… 载入字符样式… + Load Paragraph Styles… 载入段落样式… + Load All Styles… 载入所有样式… Remove Color Profile… 移除颜色配置文件… Load Image into Clipboard 将图像载入剪贴板 Load Metafile into Clipboard 将图元文件载入剪贴板 @@ -2597,6 +2612,14 @@ digits Malayalam 马拉雅拉姆数字 Relink 重新链接 Relink Linked Files to the Document 将链接文件重新链接到文档 Relink to Folder… 重新链接到文件夹… + Each Link Separately 逐个链接 + Selected Instances 所选实例 + All Instances 所有实例 + Missing Links from This Folder 此文件夹中缺失的链接 +@msg scope {v} is not each|selected|all|missingInFolder scope {v} 不是 each|selected|all|missingInFolder 之一 +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each 与 paths 需一起使用:paths 列出包含 ids 和 path 的条目,用来代替 ids、path 和 folder +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 条目 {i} 需要 path 和 ids(其他条目没有的对象 ID) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder 需要 path:将在该文件所在的文件夹中查找其他缺失的链接 Relink… 重新链接… Find Linked Files 查找链接文件 Find Linked Files in Folder… 在文件夹中查找链接文件… @@ -2641,6 +2664,7 @@ digits Malayalam 马拉雅拉姆数字 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 将其替换为其他文件,或忽略:其图像继续显示随文档保存的预览。 Replace Spine 替换脊线 Replace Symbol 替换符号 + Reset Transformation 重置变换 Replace With Font 替换为字体 Replace With Font From 替换字体来源 System 系统 @@ -2657,6 +2681,8 @@ digits Malayalam 马拉雅拉姆数字 Reset OpenType Features 重置 OpenType 特性 Reset Panel 重置面板 Reset Preferences 重置首选项 + Search Preferences 搜索首选项 + No settings found 未找到设置 Reset Profiles 重置配置文件 Reset Rotate View 重置旋转视图 Rotate View to Selection 按所选对象旋转视图 @@ -2710,6 +2736,7 @@ digits Malayalam 马拉雅拉姆数字 Rotate Tool 旋转工具 Rotate View Tool 旋转视图工具 Trackpad Gesture to Rotate View 使用触控板手势旋转视图 + Hold a Tool Shortcut to Use the Tool Temporarily 按住工具快捷键以临时使用该工具 Rotate… 旋转… Rotation 旋转 Roughen… 粗糙效果… @@ -3115,6 +3142,7 @@ digits Malayalam 马拉雅拉姆数字 Spaces 空格 Spacing Guides 间距参考线 Invoke Distance Guides 调用距离参考线 + Snap to Last Location 对齐上一位置 Spacing: 间距: Spanish 西班牙语 Specified Distance 指定距离 @@ -3900,6 +3928,7 @@ imageTrace Outline 轮廓 @msg nothing to redo 没有可重做的操作 @msg nothing to undo 没有可撤销的操作 @msg no history to clear 没有可清除的历史记录 +@msg no history state {v} 没有历史记录状态 {v} @msg Expand Appearance: the selection has a basic appearance 扩展外观:所选对象只有基本外观 @msg select (or target) objects whose appearance isn't basic 请选择 (或定位) 外观不是基本外观的对象 @msg {C}: no effect at index {from} {C}:索引 {from} 处没有效果 @@ -4453,6 +4482,7 @@ imageTrace Outline 轮廓 @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes 无法解码 {n} 张图像:它们显示为打叉的方框 @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions 有 {n} 个链接图像已在磁盘上更改:“更新链接”可显示新版本 @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk 已更新 {n} 个在磁盘上更改的链接文件 +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings 文档设置了出血,但此 PDF/X 文件未包含:请打开“使用文档的出血设置” @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} 已导出 {what},有 {n} 条注释:{first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} 个字体文件现已位于 {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} 个字体文件现已位于 {folder};有 {failed} 个无法添加:{reason} @@ -4532,6 +4562,7 @@ imageTrace Outline 轮廓 @msg No linked files to look for 没有要查找的链接文件 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} +@msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v} @msg `corners` must be an array of anchor indices, not {v} `corners` 必须是由锚点索引组成的数组,而不是 {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步骤 {i} 的结果中没有此值 (要表示 $ 本身,请写 $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步骤之前没有步骤 {i} (步骤从 0 开始计数;要表示 $ 本身,请写 $$) @@ -5033,6 +5064,9 @@ axis Both 两者 Put back in the toolbar 放回工具栏 Float Panel 浮动面板 Dock Panel 停放面板 + Dock to Top 停放到顶部 + Dock to Bottom 停放到底部 + Dock Control Bar 停放控制栏 Drag to float the toolbar 拖动以浮动工具栏 Drag to the window's left edge to dock 拖到窗口左边缘以停放 Float Tool Group 浮动工具组 @@ -5626,6 +5660,33 @@ axis Y Axis Y 轴 @msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装 +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗? + ID: {id} ID:{id} + Version: {version} 版本:{version} + File SHA-256: {hash} 文件 SHA-256:{hash} + Read the objects it is given 读取交给它的对象 + Change, add and delete objects in the document 更改、添加和删除文档中的对象 + Keep data of its own with the document 在文档中保存自己的数据 + Change how the objects its effect is applied to are drawn 更改应用了其效果的对象的绘制方式 + Write the document as a file in its own format 将文档写为其自有格式的文件 + Show its progress, see Cancel and write log lines 显示进度、检测取消请求并写入日志行 +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `progress` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} 模块以与宿主不同的类型导入 `env.{name}`:宿主的类型为 {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `progress` 时才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效工具 API 级别 2),不能使用 WASI、文件或网络 +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 为 {_1};此主机支持的增效工具 API 级别为 1 到 {API_LEVEL} +@msg `grant` must be a list of permission names `grant` 必须是权限名称的列表 +@msg `grant` names an unknown permission `{shown}` `grant` 包含未知权限 `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 类型的增效工具需要权限 `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 个 `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 权限 `{shown}` 不适用于 `{_1}` 类型的增效工具 +@msg permission `{shown}` is listed twice 权限 `{shown}` 列出了两次 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限 +@msg unknown permission `{shown}` 未知权限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 # Actions panel: action sets (#3712). New Set… 新建组… New Set 新建组 @@ -5634,6 +5695,19 @@ axis Y Axis Y 轴 Action Options… 动作选项… Action Options 动作选项 Playback Options… 播放选项… + Insert Stop… 插入停止… + Insert Stop 插入停止 + Record Stop 记录停止 + Allow Continue 允许继续 + Continue 继续 + Continue Action 继续动作 + Delete Action Step 删除动作步骤 + Press Play Current Selection in the Actions panel to go on. 要继续,请按“动作”面板中的“播放当前选择项目”。 + Stop: {message} 停止:{message} +@msg no action is waiting at a stop 没有动作在停止处等待 +@msg message must be text message 必须是文本 +@msg allowContinue must be true or false allowContinue 必须是 true 或 false +@msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作 Playback Options 播放选项 Accelerated 加速 Step by Step 逐步 @@ -6075,6 +6149,7 @@ breakText Glyphs 字形 Gap 5 间隔 5 @msg {_1}: they came in as the page's art {_1}:已作为页面图稿导入 +@msg warped text objects that came in as drawn art, not as editable type: {_1} 作为图稿而非可编辑文字导入的变形文字对象:{_1} @msg objects that drew differently from the file's own page came in as its art: {_1} 与文件自身页面绘制不同的对象已作为页面图稿导入:{_1} @msg You must choose a side pattern. 必须选择侧边图案。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 468ad851a..9440e225f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -34,6 +34,11 @@ 3 Color Job… 3 色印刷作業… 30° Angles 30° 角度 3D and Materials 3D 與材質 + Plane 平面 + Extrude 突出 + Revolve 迴轉 + Inflate 膨脹 + Select objects, then pick an object type to give them a 3D effect. 選取物件,然後挑選一種物件類型,為其加上 3D 效果。 3D Ribbon 3D 緞帶 45° Angles 45° 角度 60° Angles 60° 角度 @@ -243,6 +248,8 @@ font-technology Variable 可變 Show Cross Hairs 顯示十字線 Show Video Safe Areas 顯示視訊安全區域 Video Ruler Pixel Aspect Ratio: 視訊尺標像素長寬比: + Fade Region Outside Artboard 調暗工作區域外的區域 + Update While Dragging 拖曳時更新 Hide Video Rulers 隱藏視訊尺標 Background Contents 背景內容 Background Contents: 背景內容: @@ -1054,6 +1061,8 @@ font-technology Variable 可變 Export for Screens Settings 匯出為螢幕適用設定 Export for Screens… 匯出為螢幕適用… Export Keyboard Shortcuts… 匯出鍵盤快速鍵… + Export Keyboard Shortcuts as Text… 將鍵盤快速鍵匯出為文字… + Export Text… 匯出文字… Export Option 匯出選項 Export PDF 匯出 PDF Export PDF Presets 匯出 PDF 預設集 @@ -1352,6 +1361,8 @@ file-filter Templates 範本 Grays 灰色 Grays and Neutrals 灰色與中性色 Grayscale 灰階 + High Color 多色 + Low Color 少色 Flat Logo 扁平標誌 Grayscale images 灰階影像 Greek & Cyrillic 希臘文與西里爾文 @@ -1426,6 +1437,7 @@ file-filter Templates 範本 Step Backward 後退一步 Step Forward 前進一步 Clear History 清除步驟記錄 + New Document from State 從步驟新增文件 Set History Limit… 設定步驟記錄上限… History States 步驟記錄狀態 # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1777,6 +1789,7 @@ file-filter Templates 範本 @msg nothing to redo 沒有可重做的動作 @msg nothing to undo 沒有可復原的動作 @msg no history to clear 沒有可清除的步驟記錄 +@msg no history state {v} 沒有步驟記錄 {v} @msg Expand Appearance: the selection has a basic appearance 展開外觀:選取範圍只有基本外觀 @msg select (or target) objects whose appearance isn't basic 請選取 (或指定目標) 外觀不是基本外觀的物件 @msg {C}: no effect at index {from} {C}:索引 {from} 處沒有效果 @@ -2331,6 +2344,7 @@ file-filter Templates 範本 @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes 無法解碼 {n} 張影像:它們顯示為打叉的方框 @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions 有 {n} 個連結影像已在磁碟上變更:「更新連結」可顯示新版本 @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk 已更新 {n} 個在磁碟上變更的連結檔案 +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings 文件設定了出血,但此 PDF/X 檔案未包含:請開啟「使用文件的出血設定」 @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} 已匯出 {what},附有 {n} 則註解:{first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} 個字體檔案現已位於 {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} 個字體檔案現已位於 {folder};有 {failed} 個無法加入:{reason} @@ -2410,6 +2424,7 @@ file-filter Templates 範本 @msg No linked files to look for 沒有要尋找的連結檔案 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v} +@msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v} @msg `corners` must be an array of anchor indices, not {v} `corners` 必須是由錨點索引組成的陣列,而非 {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步驟 {i} 的結果中沒有此值 (若要表示 $ 本身,請寫 $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步驟之前沒有步驟 {i} (步驟從 0 起算;若要表示 $ 本身,請寫 $$) @@ -2698,6 +2713,9 @@ file-filter Templates 範本 Live Paint Selection Tool 即時上色選取工具 Live Shape Properties 即時形狀內容 Load Color Profile… 載入色彩描述檔… + Load Character Styles… 載入字元樣式… + Load Paragraph Styles… 載入段落樣式… + Load All Styles… 載入所有樣式… Remove Color Profile… 移除色彩描述檔… Load Image into Clipboard 將影像載入剪貼簿 Load Metafile into Clipboard 將中繼檔載入剪貼簿 @@ -3588,6 +3606,14 @@ digits Malayalam 馬來亞拉姆數字 Relink 重新連結 Relink Linked Files to the Document 將連結檔案重新連結至文件 Relink to Folder… 重新連結至資料夾… + Each Link Separately 逐一連結 + Selected Instances 所選實例 + All Instances 所有實例 + Missing Links from This Folder 此資料夾中遺失的連結 +@msg scope {v} is not each|selected|all|missingInFolder scope {v} 不是 each|selected|all|missingInFolder 其中之一 +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each 與 paths 須一起使用:paths 列出含 ids 與 path 的項目,用來取代 ids、path 與 folder +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 項目 {i} 需要 path 與 ids(其他項目沒有的物件 ID) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder 需要 path:會在該檔案所在的資料夾中尋找其他遺失的連結 Relink… 重新連結… Find Linked Files 尋找連結檔案 Find Linked Files in Folder… 在資料夾中尋找連結檔案… @@ -3632,6 +3658,7 @@ digits Malayalam 馬來亞拉姆數字 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 以其他檔案取代,或忽略:其影像會繼續顯示隨文件儲存的預覽。 Replace Spine 取代脊線 Replace Symbol 取代符號 + Reset Transformation 重設變形 Replace With Font 取代為字體 Replace With Font From 取代字體來源 System 系統 @@ -3648,6 +3675,8 @@ digits Malayalam 馬來亞拉姆數字 Reset OpenType Features 重設 OpenType 功能 Reset Panel 重設面板 Reset Preferences 重設偏好設定 + Search Preferences 搜尋偏好設定 + No settings found 找不到設定 Reset Profiles 重設描述檔 Rotate View 旋轉檢視 Reset Rotate View 重設旋轉檢視 @@ -3703,6 +3732,7 @@ digits Malayalam 馬來亞拉姆數字 Rotate Tool 旋轉工具 Rotate View Tool 旋轉檢視工具 Trackpad Gesture to Rotate View 使用觸控式軌跡板手勢旋轉檢視 + Hold a Tool Shortcut to Use the Tool Temporarily 按住工具快速鍵以暫時使用該工具 Rotate… 旋轉… Rotation 旋轉 Roughen… 粗糙效果… @@ -4142,6 +4172,7 @@ digits Malayalam 馬來亞拉姆數字 Spaces 空格 Spacing Guides 間距參考線 Invoke Distance Guides 叫用距離參考線 + Snap to Last Location 靠齊上一個位置 Spacing: 間距: Spanish 西班牙文 Specified Distance 指定距離 @@ -4844,6 +4875,9 @@ axis Both 兩者 Put back in the toolbar 放回工具列 Float Panel 浮動面板 Dock Panel 停駐面板 + Dock to Top 停駐到頂部 + Dock to Bottom 停駐到底部 + Dock Control Bar 停駐控制列 Drag to float the toolbar 拖曳以浮動工具列 Drag to the window's left edge to dock 拖曳到視窗左側邊緣以停駐 Float Tool Group 浮動工具組 @@ -5422,6 +5456,33 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝 +# Plug-in permissions (Install Plug-in…, plugin.install grant) + Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎? + ID: {id} ID:{id} + Version: {version} 版本:{version} + File SHA-256: {hash} 檔案 SHA-256:{hash} + Read the objects it is given 讀取交給它的物件 + Change, add and delete objects in the document 變更、新增和刪除文件中的物件 + Keep data of its own with the document 在文件中保存自己的資料 + Change how the objects its effect is applied to are drawn 變更套用其效果的物件的繪製方式 + Write the document as a file in its own format 將文件寫成其自有格式的檔案 + Show its progress, see Cancel and write log lines 顯示進度、偵測取消要求並寫入記錄行 +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `progress` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} 模組以與主機不同的類型匯入 `env.{name}`:主機的類型為 {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `progress` 時才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效模組 API 層級 2),不能使用 WASI、檔案或網路 +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 為 {_1};此主機支援的增效模組 API 層級為 1 到 {API_LEVEL} +@msg `grant` must be a list of permission names `grant` 必須是權限名稱的清單 +@msg `grant` names an unknown permission `{shown}` `grant` 包含未知權限 `{shown}` +@msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 類型的增效模組需要權限 `{_2}` +@msg at most {MAX_PERMISSIONS} `permissions` 最多 {MAX_PERMISSIONS} 個 `permissions` +@msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` 權限 `{shown}` 不適用於 `{_1}` 類型的增效模組 +@msg permission `{shown}` is listed twice 權限 `{shown}` 列出了兩次 +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限 +@msg unknown permission `{shown}` 未知權限 `{shown}` +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝 +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更 +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 # Actions panel: action sets (#3712). New Set… 新增組… New Set 新增組 @@ -5430,6 +5491,19 @@ axis Y Axis Y 軸 Action Options… 動作選項… Action Options 動作選項 Playback Options… 播放選項… + Insert Stop… 插入停止… + Insert Stop 插入停止 + Record Stop 記錄停止 + Allow Continue 允許繼續 + Continue 繼續 + Continue Action 繼續動作 + Delete Action Step 刪除動作步驟 + Press Play Current Selection in the Actions panel to go on. 若要繼續,請按「動作」面板中的「播放目前選取項目」。 + Stop: {message} 停止:{message} +@msg no action is waiting at a stop 沒有動作在停止處等待 +@msg message must be text message 必須是文字 +@msg allowContinue must be true or false allowContinue 必須是 true 或 false +@msg Batch cannot play an action with a stop 批次無法播放含有停止的動作 Playback Options 播放選項 Accelerated 加速 Step by Step 逐步 @@ -5872,6 +5946,7 @@ breakText Glyphs 字符 Gap 5 間隙 5 @msg {_1}: they came in as the page's art {_1}:已作為頁面圖稿匯入 +@msg warped text objects that came in as drawn art, not as editable type: {_1} 作為圖稿而非可編輯文字匯入的彎曲文字物件:{_1} @msg objects that drew differently from the file's own page came in as its art: {_1} 與檔案本身頁面繪製不同的物件已作為頁面圖稿匯入:{_1} @msg You must choose a side pattern. 必須選擇側邊圖樣。 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index d9d2b470a..4f507fe30 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,3 +1,5 @@ +use vectorcraft_plugins::permissions; + use super::save::suggested; use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json}; @@ -163,32 +165,78 @@ pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result String { + let words = match permissions::Permission::parse(name) { + Some(permissions::Permission::DocRead) => tl!("Read the objects it is given"), + Some(permissions::Permission::DocWrite) => tl!("Change, add and delete objects in the document"), + Some(permissions::Permission::State) => tl!("Keep data of its own with the document"), + Some(permissions::Permission::EffectRender) => tl!("Change how the objects its effect is applied to are drawn"), + Some(permissions::Permission::ExportWrite) => tl!("Write the document as a file in its own format"), + Some(permissions::Permission::Progress) => tl!("Show its progress, see Cancel and write log lines"), + None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), + }; + words.to_string() +} + /// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` -/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't -/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks. +/// with `name`) → `plugin.install`'s result. A plug-in that lists permissions the user hasn't +/// granted it yet (`grant`) asks them first, showing its name, id, version, file hash and what each +/// permission lets it do, and so does a filter that asks for a heavy budget the user hasn't allowed +/// yet (`allowHeavy`); OK installs it if the module still has the hash the prompt showed, Cancel +/// installs nothing → `{pending}` while it asks. 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 { - if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) { + let heavy_allowed = module.get("allowHeavy").and_then(Value::as_bool) == Some(true); + let granted = module.get("grant").is_some_and(|g| !g.is_null()); + if !heavy_allowed || !granted { 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 info = app.run("plugin.inspect", source.clone())?; - if info["budget"] == "heavy" { - let name = info["name"].as_str().unwrap_or_default(); + // What OK installs: the same module, with what the user has agreed to so far, pinned to + // the bytes the prompt shows (`hash`). + let mut answered = + Value::Object(["path", "dataBase64", "name", "replace", "grant", "allowHeavy", "hash"].into_iter().filter_map(given).collect()); + let info = app.run("plugin.inspect", source)?; + if answered.get("hash").is_none() { + answered["hash"] = info["hash"].clone(); + } + let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max); + let name = shown("name", permissions::MAX_SHOWN_NAME); + if !granted && info["needsApproval"] == true { + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]); + let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])]; + let version = shown("version", permissions::MAX_SHOWN_FIELD); + if !version.is_empty() { + lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)])); + } + lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))])); + lines.push(String::new()); + let asked = info["permissions"].as_array().cloned().unwrap_or_default(); + lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p)))); + let mut again = answered; + again["grant"] = Value::Array(asked); + dialogs::confirm::ask(app, &message, &lines.join("\n"), "ui.installPlugin", again); + return Ok(json!({ "pending": dialogs::confirm::KIND })); + } + if !heavy_allowed && info["budget"] == "heavy" { let (minutes, times) = vectorcraft_plugins::heavy_terms(); - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]); + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]); let detail = crate::i18n::fmt( tl!( "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." ), &[("minutes", &minutes.to_string()), ("times", ×.to_string())], ); - let mut allowed = source; + let mut allowed = answered; allowed["allowHeavy"] = json!(true); dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); return Ok(json!({ "pending": dialogs::confirm::KIND })); } } let r = app.run("plugin.install", module)?; - app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); + app.status(format!("Installed plug-in {}", permissions::display_text(r["name"].as_str().unwrap_or_default(), permissions::MAX_SHOWN_NAME))); Ok(r) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index afb8cbbc4..fb8912455 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -114,6 +114,7 @@ mod services; pub use app::MAX_RECENT_FONTS; pub use caches::{CacheKey, CanvasCache, LivePaintGapsCache, Perf, PrintTilingCache, SliceCache}; pub use clock::{now_ms, now_secs}; +pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] pub(crate) use services::browsable_url; pub use services::{ @@ -314,6 +315,9 @@ pub struct VectorcraftApp { /// The context, viewport and frame `shortcuts::handle` already took in this host iteration. /// Interleaving root and auxiliary viewport passes cannot make an older frame look new again. pub(crate) shortcuts_frames: Vec, + /// The single-key tool shortcuts held down, the last held last, and the tool each goes back to + /// when it is let go ([`shortcuts::HeldTool`]). + pub(crate) held_tool: Vec, /// A synthetic batch is in the host's raw input and no settled UI pass has taken it yet. synthetic_injected: bool, /// The exact events of [`Self::synthetic_injected`], to remove them if the state they belong diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dff97c9a5..3d3f2a8c0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -58,6 +58,7 @@ mod tree; mod ui_commands; pub use bar::menu_bar; +#[cfg(not(target_arch = "wasm32"))] pub(crate) use bar::menu_open; pub(crate) use bar::{home_key, home_showing}; #[cfg(test)] diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 1d8d00197..c57a21637 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -68,6 +68,7 @@ fn menu_open_id() -> egui::Id { } /// Whether a menu of the in-window menu bar is open (`false` when no menu bar was drawn yet). +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn menu_open(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(menu_open_id())).unwrap_or(false) } diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index dbee7082f..f44359973 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -448,6 +448,18 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.control_bar), + // The Control bar's panel menu and gripper: the edge of the window the bar docks to. + "window.control.dock" => { + let key = "edge"; + let edges = crate::chrome::CONTROL_BAR_EDGES; + match edges.iter().position(|e| s(key).as_deref() == Some(*e)) { + Some(i) => { + app.ui.control_bar_bottom = i == 1; + Ok(json!({ key: edges[i] })) + } + None => Err(format!("`{key}` must be one of {}", edges.join(", "))), + } + } "window.toolbar" => flag(&mut app.ui.toolbar), "window.toolbarColumns" => { let toolbar = match crate::custom_toolbars::target(&app.ui, p) { @@ -744,6 +756,9 @@ fn run_ui_part9(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::text_styles::load(app, "charStyle.load", s("path")), + "ui.loadParaStyles" => crate::panels::text_styles::load(app, "paraStyle.load", s("path")), + "ui.loadAllStyles" => crate::panels::text_styles::load(app, "textStyle.loadAll", s("path")), id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) { Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), None => Err("no such user library".into()), @@ -836,6 +851,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::links::open_file(app, p, false), "links.reveal" => crate::panels::links::open_file(app, p, true), "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), + "ui.relinkDialog" => crate::dialogs::relink::open(app, p), "ui.findLinksInFolder" => { crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned()) } @@ -848,6 +864,9 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::playback_options(app, p), crate::panels::actions::START_RECORDING => crate::panels::actions::record(app, p), crate::panels::actions::STOP_RECORDING => crate::panels::actions::stop(app, p), + crate::panels::actions::INSERT_STOP => crate::panels::actions::insert_stop(app, p), + crate::panels::actions::CONTINUE_PLAYBACK => 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; diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 963045722..ecaca0c6e 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -59,6 +59,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { // a document no item is checked. "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), "window.control" => app.ui.control_bar, + "window.control.dock" => { + p.get("edge").and_then(Value::as_str) == Some(crate::chrome::CONTROL_BAR_EDGES[usize::from(app.ui.control_bar_bottom)]) + } "window.toolbar" => app.ui.toolbar, "window.toolbarAdvanced" => app.ui.toolbar_advanced, "window.toolbarColumns" => match p.get("toolbar").and_then(Value::as_str) { @@ -157,13 +160,13 @@ pub fn shortcut_of(id: &str) -> Option<&'static str> { /// Commands that would act on the text being typed, held back while an IME composes in the Type /// tool (its marked text isn't committed yet): Undo/Redo — the native menu takes ⌘Z ahead of the /// IME —, the clipboard and the selection. -/// Clear History also waits: it ends the typing session as Undo does. +/// Clear History and New Document from State also wait: they end the typing session as Undo does. pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool { app.session.tool_composing() && (matches!( id, "edit.undo" | "edit.redo" | "edit.cut" | "edit.copy" | "edit.clear" | "edit.duplicate" | "select.all" | "select.none" | "select.inverse" - ) || id == "edit.clearHistory" + ) || matches!(id, "edit.clearHistory" | "edit.newDocumentFromState") || id.starts_with("edit.paste")) } @@ -278,6 +281,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.dxfOptionsDialog" => app.session.active().is_some(), "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), + "ui.relinkDialog" => app.services.pick_open.is_some() && (selected_image(app, |_| true) || selected_placed(app)), "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), "ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()), diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 3a55ab37c..752901c04 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -629,6 +629,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { ]; let lang = crate::i18n::current(); let mut panels = vec![ + panel("3D and Materials", crate::panels::three_d::ID), panel("Actions", "actions"), panel("Align", "align"), panel("Appearance", "appearance"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 988abad5b..208abcfeb 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -167,6 +167,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)", ), ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), + ( + "shortcuts.exportText", + "Export Keyboard Shortcuts as Text…", + "", + "{path?} save the current set's tools and menu commands with their shortcuts as a UTF-8 text file (Keyboard Shortcuts › Export Text…): the set's name, then a Tools and a Menu Commands section in the dialog's order, one `nameshortcut` line per row, empty after the tab when it has none; without `path`, pick a file", + ), ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), ( "ui.shortcutImportDialog", @@ -441,6 +447,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), ("window.control", "Control", "", "{}"), + ( + "window.control.dock", + "Dock Control Bar", + "", + "{edge: \"top\"|\"bottom\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do; kept with the workspace; another edge is an error and changes nothing; returns {edge}", + ), ("window.toolbar", "Tools", "", "{}"), ( "window.toolbarColumns", @@ -951,6 +963,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", ), + ( + "ui.loadCharStyles", + "Load Character Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character styles to the active one (engine: charStyle.load), the Character Styles panel menu's Load Character Styles…", + ), + ( + "ui.loadParaStyles", + "Load Paragraph Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's paragraph styles to the active one (engine: paraStyle.load), the Paragraph Styles panel menu's Load Paragraph Styles…", + ), + ( + "ui.loadAllStyles", + "Load All Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character and paragraph styles to the active one (engine: textStyle.loadAll), Load All Styles… in either panel's menu", + ), ( "window.userGraphicStyleLibrary1", "User Graphic Style Library 1", @@ -1229,6 +1259,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", ), + ( + "ui.relinkDialog", + "Relink…", + "", + "{ids?: [image or placed document ids]} open Relink for `ids` (default: the selected ones; dialog `relink`: ids, links ([{name, ids}], one row per linked file), scope (Replace: each|selected (default)|all|missingInFolder, see links.relink), path (picked when empty), paths (with each: the files chosen so far, one per row of links)); OK runs links.relink with them, one undo step, except with each: OK takes path, or picks a file, for the next row of links, and the last row's OK relinks them all. An error when nothing is given or selected", + ), ( "ui.findLinksInFolder", "Find Linked Files in Folder…", @@ -1269,7 +1305,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::PLAY_ACTION, "Play Action", "", - "{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays", + "{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays", ), ( crate::panels::actions::PLAYBACK_OPTIONS, @@ -1287,7 +1323,25 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::STOP_RECORDING, "Stop Playing/Recording", "", - "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}", + ), + ( + crate::panels::actions::INSERT_STOP, + "Insert Stop", + "", + "{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", + ), + ( + crate::panels::actions::CONTINUE_PLAYBACK, + "Continue Action", + "", + "{} play on from the stop the Actions panel's playing action waits at, on the document active now, and close the stop's message box (dialog `actionStopMessage`): the next step runs at once, or at Accelerated the steps up to the next stop as one undo step. The panel's Play Current Selection runs this while an action waits. With no action waiting at a stop it is an error → {results, pending, atStop?}", + ), + ( + crate::panels::actions::DELETE_STEP, + "Delete Action Step", + "", + "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}", ), ( "ui.batchDialog", @@ -1397,7 +1451,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), allowHeavy?: bool (the user agreed to its heavy budget)} 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 filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget), grant?: [permission] (the permissions the user granted it), replace?: bool, 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, and so does a filter that asks for a heavy budget: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing → plugin.install's result | {pending: \"confirm\"} while it asks", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index ff39d90cf..73d981000 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1136,9 +1136,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } let modal = modal_open(app, &ctx); // Palette windows are OS windows of their own, held above the app's window while it is active, - // so a dialog or a menu drawn in the main window would be covered by them. + // so a dialog, a menu or a tool flyout drawn in the main window would be covered by them. #[cfg(not(target_arch = "wasm32"))] - palette_window_levels(&ctx, &layout, modal, egui::Popup::is_any_open(&ctx) || crate::menus::menu_open(&ctx)); + palette_window_levels(&ctx, &layout, modal, egui::Popup::is_any_open(&ctx) || crate::menus::menu_open(&ctx) || crate::toolbar::flyout_open(&ctx)); let mut blocked = Vec::new(); if modal || egui::Popup::is_any_open(&ctx) { blocked.push(ctx.content_rect()); @@ -1147,6 +1147,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let mut dock = craft_ui::docking::DockArea::new(area) .blocked_rects(&blocked) .input_blocked(modal) + .monitors(app.services.dock_monitors.clone()) .auto_collapse_iconic(app.session.prefs.auto_collapse_icon_panels); #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index a7c661404..784b86fcf 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -42,6 +42,8 @@ struct Host { time: f64, /// Every command sent to a window, in order. commands: Vec<(ViewportId, ViewportCommand)>, + /// The monitor size each window reports. + monitor_size: Option, } impl Host { @@ -51,6 +53,7 @@ impl Host { outer_rect: Some(rect), focused: Some(self.focus == id), native_pixels_per_point: Some(1.0), + monitor_size: self.monitor_size, ..Default::default() }; std::iter::once((ViewportId::ROOT, info(ViewportId::ROOT, SCREEN.translate(ORIGIN.to_vec2())))) @@ -199,6 +202,7 @@ impl Harness { focus: ViewportId::ROOT, time: 0.0, commands: Vec::new(), + monitor_size: None, })); let shared = host.clone(); egui::Context::set_immediate_viewport_renderer(move |ctx, viewport| Host::render(&shared, ctx, viewport)); @@ -321,6 +325,217 @@ fn a_floated_panel_opens_its_own_os_window_where_the_backend_has_viewports() { assert!(tab, "its tab is drawn in the app window"); } +/// Sets the height of the OS window `viewport`, as a user's resize does. +fn resize_window(h: &mut Harness, viewport: ViewportId, height: f32) { + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().rect.set_height(height); + h.frames(3); +} + +/// A click on the `side` pt square button with the highest `rank` in the OS window `viewport`, and +/// the frames its menu takes to settle. Returns where the button is. +fn open_button_menu(h: &mut Harness, viewport: ViewportId, side: f32, rank: impl Fn(Pos2) -> f32) -> Pos2 { + let ctx = h.ctx.clone(); + let menu = ctx + .viewport_for(viewport, |vp| { + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).map(|w| (w.rect, w.sense.senses_click())).collect::>() + }) + .into_iter() + .filter(|(rect, clicks)| *clicks && rect.size() == vec2(side, side)) + .map(|(rect, _)| rect.center()) + .max_by(|a, b| rank(*a).total_cmp(&rank(*b))) + .expect("the menu's button"); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(menu), button(menu, true), button(menu, false)]); + h.frames(8); + menu +} + +/// A click on the ≡ button in the OS window `viewport`, the 16 pt square at the right end of the +/// panel's tab strip. Returns where the button is. +fn open_panel_menu(h: &mut Harness, viewport: ViewportId) -> Pos2 { + open_button_menu(h, viewport, 16.0, |at| at.x) +} + +/// The rect of the text `wanted` in the last frame of the OS window `viewport`: the last one drawn, +/// so a menu's item comes before a panel's text of the same name. +fn text_rect(h: &Harness, viewport: ViewportId, wanted: &str) -> Option { + h.host.borrow().windows.get(&viewport).unwrap().texts.iter().rfind(|(text, _)| text.trim() == wanted).map(|(_, rect)| *rect) +} + +/// Turns the wheel down by `by` points with the pointer at `over` in the OS window `viewport`, and +/// lets the scrolling finish. +fn wheel_down(h: &mut Harness, viewport: ViewportId, over: Pos2, by: f32) { + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ + Event::PointerMoved(over), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -by), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ]); + h.frames(40); +} + +/// A floating panel's window of each height keeps its ≡ menu inside it: the list scrolls, and the +/// wheel brings the last item into the window. The list opens level with the button where the +/// window has room for it, and does not slide up to fit. 100 pt is the shortest a window can be +/// resized to, and there the list takes the whole window. +#[test] +fn a_long_panel_menu_scrolls_inside_a_short_native_window() { + for (height, level_with_the_button) in [(240.0, true), (100.0, false)] { + let mut h = Harness::new(); + let viewport = h.float("layers"); + resize_window(&mut h, viewport, height); + let menu = open_panel_menu(&mut h, viewport); + if level_with_the_button { + let first = text_rect(&h, viewport, "New Layer…").expect("the menu's first item"); + assert!(first.top() >= menu.y - 16.0, "the first item at {first:?} is level with the button at {menu:?}"); + } + let last_item = |h: &Harness| text_rect(h, viewport, "Dock Panel"); + assert!(last_item(&h).is_none_or(|r| r.bottom() > height), "the {height} pt window starts at the top of the list"); + wheel_down(&mut h, viewport, pos2(menu.x - 60.0, height - 30.0), 5000.0); + let item = last_item(&h).unwrap_or_else(|| panic!("the wheel brings the menu's last item into the {height} pt window")); + assert!(item.bottom() <= height, "the last item at {item:?} is inside the {height} pt window"); + } +} + +/// The Layers menu's Save Selection submenu lists every saved selection: in a short window the +/// wheel brings the last one into view. +#[test] +fn a_long_panel_submenu_scrolls_inside_a_short_native_window() { + let mut h = Harness::new(); + h.app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + h.app.run("select.all", json!({})).unwrap(); + // A document keeps at most 25 saved selections. + for n in 0..25 { + h.app.run("select.save", json!({"name": format!("Saved {n}")})).unwrap(); + } + assert_submenu_reaches_its_end(&mut h, "layers", 240.0, "Save Selection", "Saved 0", "Saved 24"); +} + +/// The Swatches menu's Open Swatch Library lists the libraries between its head and tail items. +#[test] +fn the_library_submenu_scrolls_inside_a_short_native_window() { + let mut h = Harness::new(); + assert_submenu_reaches_its_end(&mut h, "swatches", 240.0, "Open Swatch Library", "Default Swatches", "Other Library…"); +} + +/// The Layers panel's Filter popup, under the topmost 24 pt button: the wheel brings its last row +/// into a short window. +#[test] +fn the_layers_filter_popup_scrolls_inside_a_short_native_window() { + let mut h = Harness::new(); + let viewport = h.float("layers"); + resize_window(&mut h, viewport, 160.0); + open_button_menu(&mut h, viewport, 24.0, |at| -at.y); + let over = text_rect(&h, viewport, "By Object").expect("the popup's By Object heading"); + assert!(text_rect(&h, viewport, "Hidden").is_none_or(|end| end.bottom() > 160.0), "the popup opens at the top of its list"); + wheel_down(&mut h, viewport, over.center(), 5000.0); + let end = text_rect(&h, viewport, "Hidden").expect("the wheel brings the popup's last row into the window"); + assert!(end.bottom() <= 160.0, "the last row at {end:?} is inside the window"); +} + +/// The Color Guide's Limit to Library popup, on the leftmost button of the bottom bar: with the +/// window made short while the popup is open, the wheel moves the list up past its Document +/// Swatches row. +#[test] +fn the_color_guide_library_popup_scrolls_inside_a_short_native_window() { + let mut h = Harness::new(); + let viewport = h.float("colorGuide"); + resize_window(&mut h, viewport, 320.0); + open_button_menu(&mut h, viewport, 24.0, |at| at.y * 1000.0 - at.x); + resize_window(&mut h, viewport, 100.0); + let first = text_rect(&h, viewport, "Document Swatches").expect("the popup's Document Swatches row"); + wheel_down(&mut h, viewport, first.center(), 5000.0); + let moved = text_rect(&h, viewport, "Document Swatches").is_none_or(|row| row.top() < first.top()); + assert!(moved, "the wheel scrolls the list that starts at {first:?}"); +} + +/// In an OS window of `panel`, `height` tall, scrolls the ≡ menu until its row `row` shows, clicks +/// the row to open its submenu, turns the wheel over the submenu's item `first`, and checks that its +/// item `last` comes into the window. +fn assert_submenu_reaches_its_end(h: &mut Harness, panel: &str, height: f32, row: &str, first: &str, last: &str) { + let viewport = h.float(panel); + resize_window(h, viewport, height); + let menu = open_panel_menu(h, viewport); + let inside = |h: &Harness| text_rect(h, viewport, row).filter(|rect| rect.bottom() < height - 12.0); + for _ in 0..40 { + if inside(h).is_some() { + break; + } + wheel_down(h, viewport, pos2(menu.x - 60.0, height - 30.0), 40.0); + } + let at = inside(h).expect("the row that opens the submenu").center(); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([Event::PointerMoved(at), button(at, true), button(at, false)]); + h.frames(8); + let over = text_rect(h, viewport, first).expect("the submenu's first item"); + assert!(text_rect(h, viewport, last).is_none_or(|end| end.bottom() > height), "the submenu opens at the top of its list"); + for _ in 0..3 { + if text_rect(h, viewport, last).is_some() { + break; + } + wheel_down(h, viewport, pos2(over.center().x, height - 30.0), 5000.0); + } + let end = text_rect(h, viewport, last).expect("the submenu's last item"); + assert!(end.bottom() <= height, "{last} at {end:?} is inside the {height} pt window"); +} + +/// A new launch on a desktop of `monitors`, with `saved` as the panel layout: where the window of +/// the floating Swatches panel opens. +fn reopened(saved: &craft_ui::docking::Layout, monitors: &[crate::DockMonitor]) -> Rect { + let mut h = Harness::new(); + h.host.borrow_mut().monitor_size = Some(vec2(1920.0, 1080.0)); + h.app.services.dock_monitors = monitors.to_vec(); + h.app.ui.docking = Some(saved.clone()); + h.frames(3); + let viewport = h.viewport("swatches"); + h.host.borrow().windows[&viewport].rect +} + +/// Two monitors side by side, with the main window on the left one (#5582). +#[test] +fn a_floating_panel_reopens_on_the_monitor_where_it_was_left() { + let left = crate::DockMonitor { name: Some("left".into()), rect: [0.0, 0.0, 1920.0, 1080.0], scale: 1.0 }; + let right = crate::DockMonitor { name: Some("right".into()), rect: [1920.0, 0.0, 1920.0, 1080.0], scale: 1.0 }; + let both = [left.clone(), right.clone()]; + + let mut h = Harness::new(); + h.host.borrow_mut().monitor_size = Some(vec2(1920.0, 1080.0)); + h.app.services.dock_monitors = both.to_vec(); + let viewport = h.float("swatches"); + let size = h.host.borrow().windows[&viewport].rect.size(); + let placed_at = pos2(2400.0, 300.0); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().rect = Rect::from_min_size(placed_at, size); + // The layout follows the window after craft-ui's wait for a move to settle, half a second. + h.frames(30); + + let layout = layout_of(&h.app.ui); + let window = &layout.floating[0]; + assert_eq!(window.native.map(|[x, y, ..]| pos2(x, y)), Some(placed_at)); + assert_eq!(window.monitor.as_ref(), Some(&right), "the window stores the monitor it is on"); + // The preferences keep the layout as JSON. + let saved: craft_ui::docking::Layout = serde_json::from_str(&serde_json::to_string(&layout).unwrap()).unwrap(); + + assert_eq!(reopened(&saved, &both).min, placed_at, "the monitor is connected: the saved place"); + + // The monitor got a new name and the same place. + let renamed = [left.clone(), crate::DockMonitor { name: Some("DP-2".into()), ..right.clone() }]; + assert_eq!(reopened(&saved, &renamed).min, placed_at); + + // A layout saved before the monitors were listed stores none. + let mut unstored = saved.clone(); + unstored.floating[0].monitor = None; + assert_eq!(reopened(&unstored, &both).min, placed_at, "a place on a connected monitor is kept"); + + // The monitor is gone: the main window's monitor, at the same offset from its origin. + assert_eq!(reopened(&saved, std::slice::from_ref(&left)).min, pos2(placed_at.x - 1920.0, placed_at.y)); + + // No monitor list (a host that can't list them): onto the main window's monitor by egui's size. + let no_list = reopened(&saved, &[]); + assert_eq!(no_list.min, pos2(1920.0 - size.x, placed_at.y)); +} + #[test] fn keys_typed_in_a_native_window_run_the_main_windows_shortcuts() { let mut h = Harness::new(); @@ -436,6 +651,139 @@ fn a_menu_of_the_menu_bar_lowers_the_palette_windows() { "and is hidden until the menu closes" ); } + +/// The visibility and level commands the palette window `viewport` was sent, cleared after reading. +fn take_palette_commands(h: &Harness, viewport: ViewportId) -> (Vec, Vec) { + let commands = std::mem::take(&mut h.host.borrow_mut().commands); + let shown = commands + .iter() + .filter_map(|(id, command)| match command { + ViewportCommand::Visible(visible) if *id == viewport => Some(*visible), + _ => None, + }) + .collect(); + let levels = commands + .iter() + .filter_map(|(id, command)| match command { + ViewportCommand::WindowLevel(level) if *id == viewport => Some(*level), + _ => None, + }) + .collect(); + (shown, levels) +} + +/// Opens the flyout of the group `tools` on the Tools panel. +fn open_tool_flyout(h: &mut Harness, tools: &[&str]) { + h.app.ui.flyout = Some(0); + let tools: Vec = tools.iter().map(|tool| tool.to_string()).collect(); + h.ctx.data_mut(|d| { + d.insert_temp(egui::Id::unique("flyout-tools"), tools); + d.insert_temp(crate::toolbar::flyout_toolbar_id(), None::); + }); +} + +/// A tool group's flyout is drawn in the main window, so a palette window over it hides until it +/// closes (#5813), and takes its palette level again with the keyboard left where it was. +#[test] +fn a_tool_flyout_hides_the_palette_windows_until_it_closes() { + let mut h = Harness::new(); + let viewport = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, viewport); + open_tool_flyout(&mut h, &["pen", "curvature"]); + h.frames(2); + let (shown, levels) = take_palette_commands(&h, viewport); + assert_eq!(shown, [false], "hidden while the flyout is open"); + assert_eq!(levels, [egui::WindowLevel::Normal], "and out of the top level"); + assert_eq!(h.app.ui.flyout, Some(0), "the flyout stays open"); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport), (vec![], vec![]), "the commands go once"); + h.app.ui.flyout = None; + h.frames(2); + let focus_commands = h.host.borrow().commands.iter().filter(|(_, command)| matches!(command, ViewportCommand::Focus)).count(); + assert_eq!(focus_commands, 0, "nothing takes the keyboard"); + let (shown, levels) = take_palette_commands(&h, viewport); + assert_eq!(shown, [true], "shown again when the flyout closes"); + assert_eq!(levels, [egui::WindowLevel::AlwaysOnTop], "at its palette level"); +} + +/// The All Tools drawer is drawn in the main window too, so it hides a palette window the same way. +#[test] +fn the_all_tools_drawer_hides_the_palette_windows_until_it_closes() { + let mut h = Harness::new(); + let viewport = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, viewport); + h.app.run("window.editToolbar", json!({"open": true})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden while the drawer is open"); + h.app.run("window.editToolbar", json!({"open": false})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown again when the drawer closes"); +} + +/// A flyout or a drawer left set while its toolbar is hidden does not draw, so the palette windows +/// stay on screen. +#[test] +fn a_flyout_or_drawer_of_a_hidden_toolbar_leaves_the_palette_windows_shown() { + let mut h = Harness::new(); + let viewport = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, viewport); + h.app.run("window.editToolbar", json!({"open": true})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden under the drawer"); + h.app.run("window.toolbar", json!({})).unwrap(); + h.frames(1); + assert!(!h.app.ui.toolbar && h.app.ui.tools_drawer, "the drawer is left set with the Tools panel hidden"); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown again: the drawer does not draw"); + h.app.ui.tools_drawer = false; + h.app.run("window.toolbar", json!({})).unwrap(); + open_tool_flyout(&mut h, &["pen", "curvature"]); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden under the flyout"); + // Tab hides the toolbar and the panels, and Shift+Tab shows the panels alone. + h.app.run("window.dock", json!({})).unwrap(); + h.app.run("window.panels", json!({})).unwrap(); + h.frames(1); + assert!(!h.app.ui.toolbar && h.app.ui.flyout.is_some(), "the flyout is left set with the Tools panel hidden"); + assert_eq!(take_palette_commands(&h, viewport).0.last(), Some(&true), "shown again: the flyout does not draw"); +} + +/// The frame in which a click elsewhere closes a flyout or the drawer shows the palette windows, as +/// no later frame need come while the app is idle. +#[test] +fn a_click_elsewhere_shows_the_palette_windows_in_the_frame_it_closes_a_flyout() { + let mut h = Harness::new(); + let viewport = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, viewport); + // On the dock: a press on the canvas closes a flyout later in its frame, and the release's frame + // shows the palette windows. + let at = pos2(1500.0, 700.0); + let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let click = |h: &mut Harness| { + h.frame(vec![Event::PointerMoved(at), button(true)]); + h.frame(vec![button(false)]); + }; + open_tool_flyout(&mut h, &["pen", "curvature"]); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden under the flyout"); + click(&mut h); + assert_eq!(h.app.ui.flyout, None, "the click closes the flyout"); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown in that frame"); + h.app.run("window.editToolbar", json!({"open": true})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden under the drawer"); + click(&mut h); + assert!(!h.app.ui.tools_drawer, "the click closes the drawer"); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown in that frame"); +} + /// A modal dialog is drawn in the main window, so the palettes' OS windows leave their topmost /// level while it is open and take it back after, or the dialog would be covered by them. #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index b91c563f2..84eaf8791 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -43,6 +43,7 @@ pub mod swatches; pub mod symbols; pub mod tabs; pub mod text_styles; +pub mod three_d; pub mod transform; pub mod transparency; pub mod variables; @@ -179,6 +180,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), css_properties::ID => css_properties::show(app, ui), + three_d::ID => three_d::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), @@ -242,7 +244,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool } /// The ≡ button drawn into `rect` that opens a panel menu. -fn menu_button(ui: &mut Ui, id: &str, rect: Rect) -> egui::Response { +pub(crate) fn menu_button(ui: &mut Ui, id: &str, rect: Rect) -> egui::Response { let t = Tokens::get(ui.ctx()); let resp = ui.interact(rect, ui.scope_id().with(("panel-menu", id)), Sense::click()); icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim }); @@ -517,19 +519,23 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str { crate::i18n::label_or_name(crate::i18n::current(), s, built_in) } -/// "Recent Colors" header + a row of chips (the Session's recent colours, which every paint -/// command feeds); returns the one clicked, for the caller to apply. +/// "Recent Colors" header + rows of chips (the Session's recent colours, which every paint +/// command feeds), wrapped onto as many rows as the panel's width needs; returns the one clicked, +/// for the caller to apply. pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option { let t = Tokens::get(ui.ctx()); crate::widgets::subheader(ui, tl!("Recent Colors")); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); + let width = ui.available_width(); + let colors = &app.session.recent_colors; + // Chips are 16 px on a 19 px pitch, inset 3 px, with 2 px kept clear of the right edge. + let per_row = (((width - 21.0) / 19.0).floor() as usize + 1).max(1); + let rows = colors.len().div_ceil(per_row).max(1); + let (r, _) = ui.allocate_exact_size(vec2(width, 3.0 + rows as f32 * 19.0), Sense::hover()); ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); let mut chosen = None; - for (i, c) in app.session.recent_colors.iter().enumerate() { - let cell = Rect::from_min_size(r.min + vec2(3.0 + i as f32 * 19.0, 3.0), vec2(16.0, 16.0)); - if cell.right() > r.right() - 2.0 { - break; - } + for (i, c) in colors.iter().enumerate() { + let (row, col) = (i / per_row, i % per_row); + let cell = Rect::from_min_size(r.min + vec2(3.0 + col as f32 * 19.0, 3.0 + row as f32 * 19.0), vec2(16.0, 16.0)); let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click()); crate::widgets::swatch_tile(ui, cell, &Paint::solid(*c), false, resp.hovered()); if resp.on_hover_text(c.to_hex()).clicked() { @@ -661,6 +667,8 @@ mod tests_freeform; #[cfg(test)] mod tests_links; #[cfg(test)] +mod tests_long_popups; +#[cfg(test)] mod tests_maskview; #[cfg(test)] mod tests_stroke; diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index afddf276f..64c8e6fbc 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -61,6 +61,22 @@ impl Action { } } + /// Puts `step` at index `at`; the steps from there on keep whether they are excluded. An + /// exclusion past the last step, which saved preferences can hold, goes. + fn insert_step(&mut self, at: usize, step: (String, Value)) { + let len = self.steps.len(); + self.steps.insert(at, step); + self.excluded_steps = self.excluded_steps.iter().filter(|&&i| i < len).map(|&i| if i >= at { i + 1 } else { i }).collect(); + } + + /// Removes the step at index `at`; the steps after it keep whether they are excluded. An + /// exclusion past the last step, which saved preferences can hold, goes. + fn remove_step(&mut self, at: usize) { + let len = self.steps.len(); + self.steps.remove(at); + self.excluded_steps = self.excluded_steps.iter().filter(|&&i| i < len && i != at).map(|&i| if i > at { i - 1 } else { i }).collect(); + } + /// The included steps as `command.batch` takes them. A recorded `command.batch` step (an action /// played, or a panel operation that runs as one batch, while recording) gives its own steps, /// since batches do not nest. @@ -214,6 +230,10 @@ pub fn default_sets() -> Vec { /// returns. Step by Step and Pause For run the first step now, each as its own undo step, and leave /// the rest to [`tick_playback`] → {results: [the first step's result], pending: the steps left}. /// While a paced playback is in progress, another action does not play. +/// +/// An action with an included stop ([`STOP_STEP`]) plays up to the stop and waits there at any speed +/// ([`continue_playback`]). At Accelerated, the steps between two stops are one undo step. → {results: +/// [the results of the steps run], pending: the steps left, stops included, atStop: true when it waits} pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result { let Some(a) = app.ui.action_sets.get(set).and_then(|s| s.actions.get(idx)).cloned() else { return Err("no such action".into()) }; let commands = a.included_commands(); @@ -224,8 +244,11 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result = commands.into(); let Some(first) = steps.pop_front() else { return Err("the action has no included steps".into()) }; @@ -235,7 +258,7 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result Result Result { - let r = app.run("command.batch", json!({"label": label, "commands": [step]}))?; + let r = app.replay("command.batch", json!({"label": label, "commands": [step]}))?; Ok(r.get("results").and_then(|r| r.get(0)).cloned().unwrap_or(Value::Null)) } +/// [`play`] for an action with a stop: runs what comes before the first stop, or at Step by Step and +/// Pause For the first step, and leaves the rest to [`tick_playback`] and [`continue_playback`]. +fn play_with_stops(app: &mut VectorcraftApp, label: String, commands: Vec, options: PlaybackOptions) -> Result { + let mut steps: std::collections::VecDeque = commands.into(); + let run = next_run(&mut steps, options.speed); + let ran = !run.is_empty(); + let results = if ran { play_steps(app, &label, run)? } else { vec![] }; + app.ui.action_playing = Some(Playing { label, steps, options, next_at: None, hold: false, doc: None, played_in: None, stop: None }); + after_run(app, ran); + Ok(playback_result(app, results)) +} + +/// Runs steps of a playback as one undo step, named `label` → the steps' results. +fn play_steps(app: &mut VectorcraftApp, label: &str, steps: Vec) -> Result, String> { + let r = app.run("command.batch", json!({"label": label, "commands": steps}))?; + Ok(r.get("results").and_then(Value::as_array).cloned().unwrap_or_default()) +} + +/// Takes the steps that run next as one undo step off the front of `steps`: at Accelerated all of +/// them up to the next stop, at the other speeds one. None when a stop comes next. +fn next_run(steps: &mut std::collections::VecDeque, speed: PlaybackSpeed) -> Vec { + let mut run = vec![]; + while steps.front().is_some_and(|s| !is_stop(s)) { + run.extend(steps.pop_front()); + if speed != PlaybackSpeed::Accelerated { + break; + } + } + run +} + +/// Sets up the playback in progress after steps ran (`ran`), or none did: the next pause and frame +/// start over, and a stop that comes next at Accelerated, or with no step run before it, holds now. +fn after_run(app: &mut VectorcraftApp, ran: bool) { + let doc = active_doc(app); + let played_in = app.tick_iteration.filter(|&t| t == app.host_iteration); + let Some(playing) = app.ui.action_playing.as_mut() else { return }; + playing.doc = doc; + playing.played_in = played_in; + playing.next_at = None; + playing.hold = ran && playing.options.speed == PlaybackSpeed::Step; + if (!ran || playing.options.speed == PlaybackSpeed::Accelerated) && playing.steps.front().is_some_and(is_stop) { + hold_at_stop(app); + } + if let Some(ctx) = &app.ui_ctx { + ctx.request_repaint(); + } +} + +/// What [`play`] and [`continue_playback`] return. +fn playback_result(app: &VectorcraftApp, results: Vec) -> Value { + let pending = app.ui.action_playing.as_ref().map_or(0, |p| p.steps.len()); + let mut result = json!({"results": results, "pending": pending}); + if waiting_at_stop(app) { + result["atStop"] = json!(true); + } + result +} + +/// The command id of a stop in [`Action::steps`], with {message, allowContinue} as its params. No +/// command runs under this id: playback holds where the step is. +pub const STOP_STEP: &str = "action.stop"; + +/// Whether a step as [`Action::included_commands`] gives it is a stop. +fn is_stop(step: &Value) -> bool { + step.get("command").and_then(Value::as_str) == Some(STOP_STEP) +} + +/// Whether one of the steps as [`Action::included_commands`] gives them is a stop. +pub(crate) fn has_stop(commands: &[Value]) -> bool { + commands.iter().any(is_stop) +} + +/// The message and Allow Continue setting in a stop's params; each may be left out. A saved stop +/// with no params reads as null here. A value of another type, or params that are neither null +/// nor an object, is an error. +pub(crate) fn stop_params(p: &Value) -> Result<(String, bool), String> { + if !(p.is_null() || p.is_object()) { + return Err("`params` must be an object".into()); + } + let message = match p.get("message") { + None => "", + Some(v) => v.as_str().ok_or("message must be text")?, + }; + let allow_continue = match p.get("allowContinue") { + None => false, + Some(v) => v.as_bool().ok_or("allowContinue must be true or false")?, + }; + Ok((message.to_string(), allow_continue)) +} + +/// The stop a playback waits at. A saved value of the wrong type reads as left out. +#[derive(Clone, Debug)] +struct AtStop { + message: String, + allow_continue: bool, + /// Whether the message box was shown. It waits for a dialog already open to close. + shown: bool, +} + +/// Takes the stop at the front of the playback's steps and waits there. A recording in progress +/// records the stop too. +fn hold_at_stop(app: &mut VectorcraftApp) { + let Some(playing) = app.ui.action_playing.as_mut() else { return }; + let Some(step) = playing.steps.pop_front() else { return }; + let params = step.get("params").cloned().unwrap_or(Value::Null); + let message = params.get("message").and_then(Value::as_str).unwrap_or_default().to_string(); + let allow_continue = params.get("allowContinue").and_then(Value::as_bool).unwrap_or(false); + playing.stop = Some(AtStop { message, allow_continue, shown: false }); + playing.hold = false; + if app.ui.recording.is_some() { + app.session.journal.push((STOP_STEP.to_string(), params)); + } + show_stop_message(app); +} + +/// Opens the message box of the stop the playback waits at, once, when no other dialog is open. +fn show_stop_message(app: &mut VectorcraftApp) { + if app.ui.dialog.is_some() { + return; + } + let Some(playing) = app.ui.action_playing.as_mut() else { return }; + let label = playing.label.clone(); + let Some(stop) = playing.stop.as_mut().filter(|s| !s.shown) else { return }; + stop.shown = true; + let (message, allow_continue) = (stop.message.clone(), stop.allow_continue); + crate::dialogs::action_stop::open_message(app, &label, &message, allow_continue); +} + +/// Closes the message box of a stop, if it is open. +fn close_stop_message(app: &mut VectorcraftApp) { + if app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::action_stop::MESSAGE_KIND) { + app.ui.dialog = None; + } +} + +/// Whether the playback in progress waits at a stop. +pub fn waiting_at_stop(app: &VectorcraftApp) -> bool { + app.ui.action_playing.as_ref().is_some_and(|p| p.stop.is_some()) +} + +/// The command that plays on from a stop ([`continue_playback`]). +pub const CONTINUE_PLAYBACK: &str = "ui.action.continue"; + +/// `ui.action.continue {}`: plays on from the stop the playback waits at, on the document active +/// now, and closes the stop's message box. The next step runs at once, or at Accelerated the steps +/// up to the next stop as one undo step; a failing step ends the playback with its error. With no +/// playback waiting at a stop it is an error. → {results, pending, atStop?}, as [`play`] +pub fn continue_playback(app: &mut VectorcraftApp, _: &Value) -> Result { + let Some(playing) = app.ui.action_playing.as_mut().filter(|p| p.stop.is_some()) else { + return Err("no action is waiting at a stop".into()); + }; + playing.stop = None; + let run = next_run(&mut playing.steps, playing.options.speed); + let label = playing.label.clone(); + if playing.steps.is_empty() { + app.ui.action_playing = None; + } + close_stop_message(app); + let ran = !run.is_empty(); + let results = if ran { play_steps(app, &label, run).inspect_err(|_| app.ui.action_playing = None)? } else { vec![] }; + after_run(app, ran); + Ok(playback_result(app, results)) +} + /// The speed actions play at (Actions panel menu › Playback Options…). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -311,6 +499,9 @@ pub struct Playing { /// /// [`tick`]: VectorcraftApp::tick played_in: Option, + /// The stop the playback waits at, if any: no step runs until [`continue_playback`] or + /// [`stop`]. + stop: Option, } fn active_doc(app: &VectorcraftApp) -> Option { @@ -324,11 +515,15 @@ fn active_doc(app: &VectorcraftApp) -> Option { /// frame does not shorten it. A step that fails ends the playback and shows its error. When a step /// is due and the active document is not the one that was active after the last step (another /// document was made active, or that one was closed), the playback ends with a status and the rest -/// do not run. → the seconds until the window should draw again while playback goes on (none while +/// do not run, except that a stop is held whichever document is active. → the seconds until the window should draw again while playback goes on (none while /// Step by Step waits for a UI pass, which asks for the next frame itself) pub fn tick_playback(app: &mut VectorcraftApp, now: f64) -> Option { let doc = active_doc(app); let playing = app.ui.action_playing.as_mut()?; + if playing.stop.is_some() { + show_stop_message(app); + return None; + } if playing.hold { return None; } @@ -342,6 +537,10 @@ pub fn tick_playback(app: &mut VectorcraftApp, now: f64) -> Option { return Some(due - now); } } + if playing.steps.front().is_some_and(is_stop) { + hold_at_stop(app); + return None; + } if playing.doc != doc { app.ui.action_playing = None; app.status("the action stopped playing: its document is no longer active"); @@ -649,16 +848,114 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { /// `ui.action.stop {}`: ends the recording in progress and keeps its steps, in the action New /// Action made for it, else in a new action after the last action of its set, as the bottom bar's -/// Stop Playing/Recording does. It also ends a playback at Step by Step or Pause For: the steps -/// already played stay, and the rest do not run. With nothing recording or playing it changes -/// nothing. → {stopped} +/// Stop Playing/Recording does. It also ends a playback at Step by Step or Pause For, or one that +/// waits at a stop, whose message box closes: the steps already played stay, and the rest do not +/// run. With nothing recording or playing it changes nothing. → {stopped} pub fn stop(app: &mut VectorcraftApp, _: &Value) -> Result { let playing = app.ui.action_playing.take().is_some(); + close_stop_message(app); let recording = app.ui.recording.is_some(); stop_recording(app); Ok(json!({ "stopped": recording || playing })) } +/// The command that inserts a stop ([`insert_stop`]). +pub const INSERT_STOP: &str = "ui.action.insertStop"; + +/// The panel state that holds the selected step as (the action's [`Action::id`], the step's index). +const SELECTED_STEP: &str = "actions-selected-step"; + +/// `ui.action.insertStop {set?, action?, step?, message?, allowContinue?}`: inserts a stop. With `set` +/// and `action`, it goes into that action, after step `step`, else after its last step. Without +/// them, it goes into the recording in progress, after the steps recorded so far. `message` is +/// the text playback shows (default: none), and `allowContinue` offers Continue in the message box +/// (default: false). An index past the last set, action or step, a param of another type, params +/// that are not an object, or no action and no recording is an error, and then nothing changes. +/// Record Stop's OK runs this. A stop in an action becomes the selected step, and its action and set +/// become the selected ones. → {set, action, step}, or {recording: true} +pub fn insert_stop(app: &mut VectorcraftApp, p: &Value) -> Result { + if !p.is_object() { + return Err("`params` must be an object".into()); + } + let (message, allow_continue) = stop_params(p)?; + let step = (STOP_STEP.to_string(), json!({"message": message, "allowContinue": allow_continue})); + let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + if p.get("set").is_none() && p.get("action").is_none() && p.get("step").is_none() { + if app.ui.recording.is_none() { + return Err("no such action".into()); + } + app.session.journal.push(step); + return Ok(json!({"recording": true})); + } + let (set, at) = index("set").zip(index("action")).ok_or("no such action")?; + let action = app.ui.action_sets.get_mut(set).and_then(|s| s.actions.get_mut(at)).ok_or("no such action")?; + let after = match p.get("step") { + None => action.steps.len(), + Some(_) => index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")? + 1, + }; + action.insert_step(after, step); + let id = action.id; + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; + if let Some(ctx) = app.ui_ctx.clone() { + set_pstate::>(&ctx, "actions-selected", Some((set, at))); + set_pstate::>(&ctx, SELECTED_STEP, Some((id, after))); + let mut expanded: BTreeSet = pstate(&ctx, "actions-expanded"); + expanded.insert(id); + set_pstate(&ctx, "actions-expanded", expanded); + } + Ok(json!({"set": set, "action": at, "step": after})) +} + +/// The selected step of the action `selected`, if it has one and the panel shows its steps. +fn selected_step(app: &VectorcraftApp, ctx: &egui::Context, selected: Option<(usize, usize)>) -> Option { + let (s, a) = selected?; + let action = app.ui.action_sets.get(s)?.actions.get(a)?; + if !pstate::>(ctx, "actions-expanded").contains(&action.id) { + return None; + } + pstate::>(ctx, SELECTED_STEP).filter(|&(id, step)| id == action.id && step < action.steps.len()).map(|(_, step)| step) +} + +/// The command that deletes one step of an action ([`delete_step`]). +pub const DELETE_STEP: &str = "ui.action.deleteStep"; + +/// `ui.action.deleteStep {set, action, step}`: deletes step `step`, a stop or a command, of the +/// action at index `action` of set `set`. The steps after it keep whether they are excluded, and +/// no step of that action stays selected. A Record Stop dialog open on that step closes, and one +/// open on a later step of the action follows it up. A missing or invalid index is an error, and +/// then nothing changes. Undo does not bring the step back. Delete Selection runs this for the +/// selected step. → {set, action, step} +pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result { + let number = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()); + let index = |k: &str| p.get(k).and_then(number); + let (set, at) = index("set").zip(index("action")).ok_or("no such action")?; + let action = app.ui.action_sets.get_mut(set).and_then(|s| s.actions.get_mut(at)).ok_or("no such action")?; + let step = index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")?; + action.remove_step(step); + let id = action.id; + if let Some(ctx) = app.ui_ctx.clone() + && pstate::>(&ctx, SELECTED_STEP).is_some_and(|(selected, _)| selected == id) + { + set_pstate::>(&ctx, SELECTED_STEP, None); + } + let held = app + .ui + .dialog + .as_ref() + .filter(|d| d.kind == crate::dialogs::action_stop::RECORD_KIND) + .filter(|d| d.fields.get("set").and_then(number) == Some(set) && d.fields.get("action").and_then(number) == Some(at)) + .and_then(|d| d.fields.get("step").and_then(number)); + if held == Some(step) { + app.ui.dialog = None; + } else if let Some(later) = held.filter(|&s| s > step) + && let Some(d) = app.ui.dialog.as_mut() + { + d.fields.insert("step".into(), json!(later - 1)); + } + Ok(json!({"set": set, "action": at, "step": step})) +} + /// The command that `chord` runs under `km`, pressed once or pressed twice. #[cfg(test)] fn command_on(km: &craft_keymap::Keymap, chord: &craft_keymap::Chord) -> Option { @@ -786,12 +1083,20 @@ fn selected_sets(app: &VectorcraftApp) -> Vec { if current.is_some_and(|c| sets.contains(&c)) { sets } else { current.into_iter().collect() } } -/// Delete Selection, from the bottom bar and the panel menu: deletes the selected action, else asks -/// whether to delete the selected sets ([`DELETE_SETS`]). +/// Delete Selection, from the bottom bar and the panel menu: deletes the selected step +/// ([`DELETE_STEP`]), else the selected action, else asks whether to delete the selected sets +/// ([`DELETE_SETS`]). fn delete_selection(app: &mut VectorcraftApp, ctx: &egui::Context) { const SELECTED: &str = "actions-selected"; // A selected action counts only while its set is the current one, as the panel shows it. - if let Some((si, ai)) = pstate::>(ctx, SELECTED).filter(|&(s, _)| app.ui.action_set == Some(s)) { + let selected = pstate::>(ctx, SELECTED).filter(|&(s, _)| app.ui.action_set == Some(s)); + if let Some(((set, action), step)) = selected.zip(selected_step(app, ctx, selected)) { + if let Err(e) = app.run(DELETE_STEP, json!({"set": set, "action": action, "step": step})) { + app.status(e); + } + return; + } + if let Some((si, ai)) = selected { if let Some(s) = app.ui.action_sets.get_mut(si) && ai < s.actions.len() { @@ -885,6 +1190,7 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result fn holds_set(d: &crate::state::Dialog) -> bool { d.kind == crate::dialogs::action_batch::KIND || d.kind == crate::dialogs::action_new::KIND + || d.kind == crate::dialogs::action_stop::RECORD_KIND || (d.kind == crate::dialogs::action_set_options::KIND && d.fields.contains_key("set")) } @@ -934,8 +1240,9 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { let Some(mut copy) = src.actions.get(a).map(Action::copy) else { return Err("no such action".into()) }; copy.name = format!("{} copy", copy.name); src.actions.insert(a + 1, copy); - // A Batch dialog open on a later action of the set follows it down. - if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND) + // A Batch or Record Stop dialog open on a later action of the set follows it down. + if let Some(d) = + app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || d.kind == crate::dialogs::action_stop::RECORD_KIND) && d.fields.get("set").and_then(index) == Some(set) && let Some(da) = d.fields.get("action").and_then(index).filter(|&da| da > a) { @@ -998,11 +1305,17 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) { { app.status(e); } - if widgets::menu_item(ui, tl!("Play Current Selection"), has, false) - && let Some((s, a)) = selected - && let Err(e) = play(app, s, a) - { - app.status(e); + let waiting = waiting_at_stop(app); + if widgets::menu_item(ui, tl!("Play Current Selection"), has || waiting, false) { + let played = if waiting { app.run(CONTINUE_PLAYBACK, json!({})) } else { selected.map_or(Ok(Value::Null), |(s, a)| play(app, s, a)) }; + if let Err(e) = played { + app.status(e); + } + } + let recording = app.ui.recording.is_some(); + if widgets::menu_item(ui, tl!("Insert Stop…"), recording || has, false) { + let step = selected_step(app, ui.ctx(), selected); + crate::dialogs::action_stop::open_record(app, if recording { None } else { selected }, step); } if widgets::menu_item(ui, tl!("Action Options…"), has, false) && let Some((s, a)) = selected @@ -1075,6 +1388,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if selected.is_some_and(|(s, _)| app.ui.action_set != Some(s)) { selected = None; } + // The selected step as (the action's id, the step), of the selected action only. + let mut step_selected = + selected_step(app, ui.ctx(), selected).zip(selected).and_then(|(step, (s, a))| Some((app.ui.action_sets[s].actions.get(a)?.id, step))); let mut current = app.ui.action_set; let mut sets_selected = selected_sets(app); let shift = ui.input(|i| i.modifiers.shift); @@ -1115,6 +1431,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } } selected = None; + step_selected = None; } for (ai, a) in set.actions.iter().enumerate() { let sel = selected == Some((si, ai)); @@ -1158,6 +1475,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { ); if resp.clicked() && !resp.interact_pointer_pos().is_some_and(|p| check_rect.contains(p) || disclosure.contains(p)) { selected = Some((si, ai)); + step_selected = None; current = Some(si); sets_selected = vec![si]; } @@ -1166,18 +1484,34 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } if open { for (step, (command, params)) in a.steps.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::hover()); - if resp.hovered() { + let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click()); + if step_selected == Some((a.id, step)) { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } - let (clicked, _) = inclusion_box(ui, r, 55.0, ("step-inclusion", a.id, step), !a.excluded_steps.contains(&step), false); + let (clicked, check_rect) = + inclusion_box(ui, r, 55.0, ("step-inclusion", a.id, step), !a.excluded_steps.contains(&step), false); if clicked { inclusion_changes.push(InclusionChange::Step(si, ai, step, alt)); } + if resp.clicked() && !resp.interact_pointer_pos().is_some_and(|p| check_rect.contains(p)) { + selected = Some((si, ai)); + step_selected = Some((a.id, step)); + current = Some(si); + sets_selected = vec![si]; + } + // A stop's row shows its message; other steps show their command. + let shown = if command == STOP_STEP { + let message = params.get("message").and_then(Value::as_str).and_then(|m| m.lines().next()).unwrap_or_default(); + crate::i18n::fmt(tl!("Stop: {message}"), &[("message", message)]) + } else { + command.clone() + }; ui.painter().text( r.left_center() + egui::vec2(70.0, 0.0), egui::Align2::LEFT_CENTER, - command, + shown, egui::FontId::proportional(11.0), t.text_dim, ); @@ -1189,6 +1523,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } } }); + if waiting_at_stop(app) { + dim_label(ui, tl!("Press Play Current Selection in the Actions panel to go on.")); + } for change in inclusion_changes { let params = match change { InclusionChange::Set(si) => { @@ -1211,6 +1548,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } set_pstate(ui.ctx(), "actions-expanded", expanded); set_pstate(ui.ctx(), SELECTED, selected); + set_pstate(ui.ctx(), SELECTED_STEP, step_selected); app.ui.action_set = current; app.ui.action_sets_selected = sets_selected; widgets::divider(ui); @@ -1230,10 +1568,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { { app.status(e); } - if widgets::icon_button(ui, "dc-actions", tl!("Play Current Selection"), false, 24.0).clicked() - && let Some(s) = selected - { - play_req = Some(s); + if widgets::icon_button(ui, "dc-actions", tl!("Play Current Selection"), false, 24.0).clicked() { + if waiting_at_stop(app) { + if let Err(e) = app.run(CONTINUE_PLAYBACK, json!({})) { + app.status(e); + } + } else if let Some(s) = selected { + play_req = Some(s); + } } if widgets::icon_button(ui, "dc-folder", tl!("Create New Set"), false, 24.0).clicked() { crate::dialogs::action_set_options::open_new(app); @@ -1277,6 +1619,30 @@ mod tests { } } + /// An action recorded with a key its command no longer reads still plays, accelerated and one + /// step at a time, though the same command run by itself is refused. + #[test] + fn an_action_with_a_key_no_longer_read_still_plays() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let id = vectorcraft_doc::NodeId( + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(), + ); + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + let old = json!({"dx": 5, "dy": 0, "dyy": 1}); + assert!(app.run("object.move", old.clone()).is_err()); + app.ui.action_sets = + vec![ActionSet::new("Test", vec![Action::new("Move", vec![("object.move".into(), old.clone()), ("object.move".into(), old)])])]; + let x = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + let before = x(&app); + play(&mut app, 0, 0).unwrap(); + assert!((x(&app) - before - 10.0).abs() < 1e-9, "both moves ran"); + app.ui.action_playback.speed = PlaybackSpeed::Step; + assert_eq!(play(&mut app, 0, 0).unwrap()["pending"], json!(1)); + assert!((x(&app) - before - 15.0).abs() < 1e-9, "the first step ran"); + assert!(!app.session.replaying()); + } + #[test] fn excluded_steps_do_not_play_and_survive_saved_sets() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -2280,7 +2646,7 @@ mod tests { (json!(long.to_string()), "an action in the action set file has more than 10,000 steps"), (json!(" ".repeat(files::MAX_SET_FILE + 1)), "the action set file is larger than 8 MiB"), ( - json!("{\"format\": \"vcactions\", \"version\": 3, \"name\": \"S\", \"actions\": []}"), + json!("{\"format\": \"vcactions\", \"version\": 4, \"name\": \"S\", \"actions\": []}"), "the action set file needs a later version of VectorCraft", ), ] { @@ -3294,4 +3660,592 @@ mod tests { assert_eq!(frame(&mut app, 0.0), None); assert_eq!(x(&app), 1.0, "the step after the failing one does not run"); } + + /// [`app_with_three_moves`] with a stop after the first move. + fn app_with_a_stop(allow_continue: bool) -> (VectorcraftApp, impl Fn(&VectorcraftApp) -> f64) { + let (mut app, x) = app_with_three_moves(); + let params = json!({"set": 0, "action": 0, "step": 0, "message": "Paint it", "allowContinue": allow_continue}); + assert_eq!(app.run(INSERT_STOP, params).unwrap(), json!({"set": 0, "action": 0, "step": 1})); + (app, x) + } + + fn open_kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) + } + + #[test] + fn insert_stop_goes_after_the_given_step_or_at_the_end_and_keeps_exclusions() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 2]); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0, "message": "After the first"})).unwrap(); + app.run(INSERT_STOP, json!({"set": 0, "action": 0})).unwrap(); + let action = &app.ui.action_sets[0].actions[0]; + let ids: Vec<&str> = action.steps.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["object.move", STOP_STEP, "object.move", "object.move", STOP_STEP]); + assert_eq!(action.steps[1].1, json!({"message": "After the first", "allowContinue": false})); + assert_eq!(action.steps[4].1, json!({"message": "", "allowContinue": false}), "the message and Allow Continue are optional"); + assert_eq!(action.excluded_steps, BTreeSet::from([0, 3]), "the step excluded after the insertion point moved with it"); + + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([1, usize::MAX]); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([2]), "an exclusion past the last step is dropped"); + } + + #[test] + fn insert_stop_refuses_bad_params_and_changes_nothing() { + let (mut app, _) = app_with_three_moves(); + for (params, want) in [ + (json!({"message": "m"}), "no such action"), + (json!({"set": 0}), "no such action"), + (json!({"action": 0}), "no such action"), + (json!({"step": 0}), "no such action"), + (json!({"set": 1, "action": 0}), "no such action"), + (json!({"set": 0, "action": 1}), "no such action"), + (json!({"set": "0", "action": 0}), "no such action"), + (json!({"set": 0, "action": null}), "no such action"), + (json!({"set": 0, "action": 0, "step": 3}), "no such action step"), + (json!({"set": 0, "action": 0, "step": -1}), "no such action step"), + (json!({"set": 0, "action": 0, "step": null}), "no such action step"), + (json!({"set": 0, "action": 0, "message": 5}), "message must be text"), + (json!({"set": 0, "action": 0, "message": null}), "message must be text"), + (json!({"set": 0, "action": 0, "allowContinue": "yes"}), "allowContinue must be true or false"), + (json!(5), "`params` must be an object"), + (json!([1]), "`params` must be an object"), + (json!("message"), "`params` must be an object"), + (json!(null), "`params` must be an object"), + ] { + assert_eq!(app.run(INSERT_STOP, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}"); + } + app.run(START_RECORDING, json!({})).unwrap(); + assert_eq!(app.run(INSERT_STOP, json!({"message": 5})).unwrap_err(), "message must be text"); + for params in [json!(5), json!([1]), json!(true), json!(null)] { + assert_eq!(app.run(INSERT_STOP, params.clone()).unwrap_err(), "`params` must be an object", "{params}"); + } + app.run(STOP_RECORDING, json!({})).unwrap(); + assert!(app.ui.action_sets[0].actions.last().unwrap().steps.is_empty(), "the refused stop is not recorded"); + } + + #[test] + fn delete_step_removes_one_step_and_moves_the_exclusions_up() { + let (mut app, _) = app_with_a_stop(false); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 1, 3, usize::MAX]); + assert_eq!(app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 1})).unwrap(), json!({"set": 0, "action": 0, "step": 1})); + let action = &app.ui.action_sets[0].actions[0]; + let ids: Vec<&str> = action.steps.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["object.move", "object.move", "object.move"]); + assert_eq!(action.excluded_steps, BTreeSet::from([0, 2]), "the deleted step's exclusion goes and a later one moves up"); + } + + #[test] + fn delete_step_refuses_bad_params_and_changes_nothing() { + let (mut app, _) = app_with_three_moves(); + for (params, want) in [ + (json!({}), "no such action"), + (json!(5), "no such action"), + (json!([1]), "no such action"), + (json!({"set": 1, "action": 0, "step": 0}), "no such action"), + (json!({"set": 0, "action": 1, "step": 0}), "no such action"), + (json!({"set": "0", "action": 0, "step": 0}), "no such action"), + (json!({"set": 0, "action": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 3}), "no such action step"), + (json!({"set": 0, "action": 0, "step": -1}), "no such action step"), + (json!({"set": 0, "action": 0, "step": null}), "no such action step"), + ] { + assert_eq!(app.run(DELETE_STEP, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}"); + } + } + + #[test] + fn delete_step_keeps_an_open_record_stop_on_its_place() { + let (mut app, _) = app_with_three_moves(); + crate::dialogs::action_stop::open_record(&mut app, Some((0, 0)), Some(2)); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(1), "a deleted step above moves it up"); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 1})).unwrap(); + assert_eq!(open_kind(&app), None, "deleting its step closes it"); + } + + /// Selects the first step of "Three Moves" by clicking its row, with the action open. + fn click_first_step(ctx: &egui::Context, app: &mut VectorcraftApp) { + use super::super::tests_appearance::{click, frame_events}; + set_pstate(ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = frame_events(ctx, app, vec![], show); + let first = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap(); + click(ctx, app, first.center(), show); + } + + #[test] + fn delete_selection_on_a_selected_step_deletes_that_step_and_keeps_the_action() { + use super::super::tests_appearance::click; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([2]); + click_first_step(&ctx, &mut app); + let delete = trash(&ctx, &mut app); + click(&ctx, &mut app, delete, show); + let action = &app.ui.action_sets[0].actions[0]; + assert_eq!(action.name, "Three Moves", "the action stays"); + assert_eq!(action.steps.len(), 2, "one step goes"); + assert_eq!(action.excluded_steps, BTreeSet::from([1])); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), None, "no step stays selected"); + assert_eq!(pstate::>(&ctx, "actions-selected"), Some((0, 0)), "the action stays selected"); + + let delete = trash(&ctx, &mut app); + click(&ctx, &mut app, delete, show); + assert!(app.ui.action_sets[0].actions.is_empty(), "with no step selected, the selected action goes"); + } + + #[test] + fn the_panel_menus_delete_deletes_the_selected_step_and_a_stop_inserted_there() { + use super::super::tests_appearance::{click, frame_events}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + click_first_step(&ctx, &mut app); + menu_insert_stop(&ctx, &mut app, "Paint it", false); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 4); + let texts = frame_events(&ctx, &mut app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Delete").map(|(_, r)| *r).expect("Delete in the menu"); + click(&ctx, &mut app, item.center(), menu); + let action = &app.ui.action_sets[0].actions[0]; + assert!(action.steps.iter().all(|(c, _)| c == "object.move"), "the new stop was selected, and it goes"); + assert_eq!(action.steps.len(), 3); + } + + #[test] + fn a_stop_inserted_into_another_action_selects_it_and_delete_selection_deletes_the_stop() { + use super::super::tests_appearance::click; + let (mut app, _) = app_with_three_moves(); + let step = ("object.move".to_string(), json!({"dx": 8, "dy": 0})); + app.ui.action_sets[0].actions.push(Action::new("Same Set", vec![step.clone()])); + app.ui.action_sets.push(ActionSet::new("More", vec![Action::new("Other Set", vec![step])])); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + for (set, at) in [(0, 1), (1, 0)] { + app.ui.action_set = Some(0); + app.ui.action_sets_selected = vec![0]; + set_pstate::>(&ctx, "actions-selected", Some((0, 0))); + app.run(INSERT_STOP, json!({"set": set, "action": at, "message": "Paint it"})).unwrap(); + assert_eq!(app.ui.action_set, Some(set)); + assert_eq!(app.ui.action_sets_selected, vec![set]); + let selected = pstate::>(&ctx, "actions-selected"); + assert_eq!(selected, Some((set, at)), "the stop's action is the selected one"); + assert_eq!(selected_step(&app, &ctx, selected), Some(1), "the new stop is the selected step"); + let delete = trash(&ctx, &mut app); + click(&ctx, &mut app, delete, show); + let action = &app.ui.action_sets[set].actions[at]; + assert!(action.steps.iter().all(|(c, _)| c == "object.move"), "the stop goes"); + assert_eq!(action.steps.len(), 1, "the command stays"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "the action selected before keeps its steps"); + } + assert_eq!(app.ui.action_sets[0].actions.len(), 2); + assert_eq!(app.ui.action_sets[1].actions.len(), 1); + } + + #[test] + fn a_step_in_a_closed_action_is_not_the_selection() { + use super::super::tests_appearance::click; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + click_first_step(&ctx, &mut app); + set_pstate(&ctx, "actions-expanded", BTreeSet::::new()); + let delete = trash(&ctx, &mut app); + click(&ctx, &mut app, delete, show); + assert!(app.ui.action_sets[0].actions.is_empty(), "the step's row is hidden and the action's row is the one highlighted"); + } + + #[test] + fn a_stop_inserted_while_recording_goes_at_the_recording_position() { + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); + app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(app.run(INSERT_STOP, json!({"message": "Now", "allowContinue": true})).unwrap(), json!({"recording": true})); + app.run("object.move", json!({"dx": 2, "dy": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + let ids: Vec<&str> = recorded.steps.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["object.move", STOP_STEP, "object.move"]); + assert_eq!(recorded.steps[1].1, json!({"message": "Now", "allowContinue": true})); + } + + #[test] + fn accelerated_playback_holds_at_a_stop_and_play_plays_the_rest() { + let (mut app, x) = app_with_a_stop(false); + let undo = undo_steps(&app); + let r = play(&mut app, 0, 0).unwrap(); + assert_eq!((r["pending"].clone(), r["atStop"].clone(), r["results"].as_array().map(Vec::len)), (json!(2), json!(true), Some(1))); + assert_eq!(x(&app), 1.0, "the step before the stop runs"); + let d = app.ui.dialog.as_ref().expect("the stop shows its message"); + assert_eq!( + (d.kind.as_str(), d.str("action"), d.str("message"), d.bool("allowContinue")), + (crate::dialogs::action_stop::MESSAGE_KIND, "Three Moves".to_string(), "Paint it".to_string(), false) + ); + for now in [0.0, 100.0, 1000.0] { + assert_eq!(frame(&mut app, now), None); + assert_eq!(x(&app), 1.0, "no later step runs while the action waits"); + } + assert_eq!(play(&mut app, 0, 0).unwrap_err(), "an action is already playing"); + let r = app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!((r["pending"].clone(), r["atStop"].clone()), (json!(0), Value::Null)); + assert_eq!(x(&app), 7.0, "Play runs every step after the stop"); + assert!(app.ui.action_playing.is_none()); + assert_eq!(open_kind(&app), None, "the message box closes"); + assert_eq!(undo_steps(&app), undo + 2, "the steps on each side of the stop are one undo step each"); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap_err(), "no action is waiting at a stop"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(x(&app), 1.0, "one undo takes back the steps after the stop"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(x(&app), 0.0, "and one more the steps before it"); + } + + #[test] + fn step_by_step_and_pause_for_hold_at_a_stop() { + for (speed, seconds) in [("step", 1), ("pause", 2)] { + let (mut app, x) = app_with_a_stop(false); + app.run(PLAYBACK_OPTIONS, json!({"speed": speed, "seconds": seconds})).unwrap(); + let r = play(&mut app, 0, 0).unwrap(); + assert_eq!((r["pending"].clone(), r["atStop"].clone()), (json!(3), Value::Null), "{speed}"); + assert_eq!(open_kind(&app), None, "{speed}: the stop comes after the step before it is shown"); + let mut now = 0.0; + while !waiting_at_stop(&app) { + assert!(now < 100.0, "{speed}: the playback reaches the stop"); + frame(&mut app, now); + now += 1.0; + } + assert_eq!(open_kind(&app), Some(crate::dialogs::action_stop::MESSAGE_KIND), "{speed}"); + for _ in 0..5 { + assert_eq!(frame(&mut app, now + 1000.0), None, "{speed}"); + assert_eq!(x(&app), 1.0, "{speed}: no later step runs while the action waits"); + } + let r = app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(r["pending"], json!(1), "{speed}: Play runs the next step, and the last stays for its turn"); + assert_eq!(x(&app), 3.0, "{speed}"); + for _ in 0..20 { + if app.ui.action_playing.is_none() { + break; + } + frame(&mut app, now); + now += 10.0; + } + assert_eq!(x(&app), 7.0, "{speed}: the paced playback goes on to the end"); + assert!(app.ui.action_playing.is_none(), "{speed}"); + } + } + + #[test] + fn a_paced_playback_holds_at_a_stop_when_another_document_became_active() { + let (mut app, _) = app_with_a_stop(false); + app.run(PLAYBACK_OPTIONS, json!({"speed": "pause", "seconds": 1})).unwrap(); + play(&mut app, 0, 0).unwrap(); + assert_eq!(tick_playback(&mut app, 0.0), Some(1.0)); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + app.run("shape.rectangle", json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap(); + assert_eq!(tick_playback(&mut app, 1.0), None); + assert!(waiting_at_stop(&app), "the stop holds the playback, as it does at Accelerated"); + assert_ne!(app.ui.status, "the action stopped playing: its document is no longer active"); + let r = app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(r["pending"], json!(1), "Continue runs the next step in the document active now"); + } + + #[test] + fn allow_continue_offers_continue_and_stop_closes_the_box_and_keeps_waiting() { + let (mut app, x) = app_with_a_stop(true); + play(&mut app, 0, 0).unwrap(); + assert!(dialog_texts(&mut app).iter().any(|s| s == "Continue"), "Allow Continue shows Continue"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(x(&app), 7.0, "Continue plays the rest"); + assert!(app.ui.action_playing.is_none()); + assert_eq!(open_kind(&app), None); + + let (mut app, x) = app_with_a_stop(false); + play(&mut app, 0, 0).unwrap(); + let texts = dialog_texts(&mut app); + assert!(!texts.iter().any(|s| s == "Continue"), "without Allow Continue there is no Continue: {texts:?}"); + assert!(texts.iter().any(|s| s == "Paint it") && texts.iter().any(|s| s == "Stop"), "{texts:?}"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(open_kind(&app), None, "the box's only button closes it"); + assert!(waiting_at_stop(&app), "the action waits for the manual task"); + assert_eq!(x(&app), 1.0); + frame(&mut app, 100.0); + assert_eq!(open_kind(&app), None, "the box does not open again"); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(x(&app), 7.0); + + let (mut app, x) = app_with_a_stop(true); + play(&mut app, 0, 0).unwrap(); + crate::dialogs::cancel(&mut app); + assert!(waiting_at_stop(&app), "Stop closes the box and the action waits"); + assert_eq!(x(&app), 1.0); + } + + #[test] + fn play_in_the_panel_plays_on_from_a_stop_with_nothing_selected() { + use super::super::tests_appearance::{click, frame_events}; + let (mut app, x) = app_with_a_stop(false); + play(&mut app, 0, 0).unwrap(); + crate::dialogs::cancel(&mut app); + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(s, _)| s.contains("Play Current Selection")), "the panel says how to go on: {texts:?}"); + let mut bar = crate::toolbar::tests::widget_rects(&ctx, egui::vec2(24.0, 24.0)); + bar.sort_by(|a, b| a.left().total_cmp(&b.left())); + let [_, _, play_button, _, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + click(&ctx, &mut app, play_button.center(), show); + assert_eq!(x(&app), 7.0); + assert!(app.ui.action_playing.is_none()); + + let (mut app, x) = app_with_a_stop(false); + play(&mut app, 0, 0).unwrap(); + crate::dialogs::cancel(&mut app); + let texts = frame_events(&ctx, &mut app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Play Current Selection").map(|(_, r)| *r).expect("the item in the menu"); + click(&ctx, &mut app, item.center(), menu); + assert_eq!(x(&app), 7.0, "the panel menu's item plays on too, with nothing selected"); + } + + #[test] + fn stop_at_a_stop_ends_the_playback_and_keeps_the_steps_run() { + let (mut app, x) = app_with_a_stop(false); + let undo = undo_steps(&app); + play(&mut app, 0, 0).unwrap(); + assert_eq!(app.run(STOP_RECORDING, json!({})).unwrap(), json!({"stopped": true})); + assert!(app.ui.action_playing.is_none()); + assert_eq!(open_kind(&app), None, "the message box closes"); + assert_eq!(frame(&mut app, 100.0), None); + assert_eq!(x(&app), 1.0, "the steps after the stop do not run; the one played stays"); + assert_eq!(undo_steps(&app), undo + 1); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap_err(), "no action is waiting at a stop"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(x(&app), 0.0, "the step played undoes"); + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app), "the action plays again after Stop"); + } + + #[test] + fn stops_first_last_and_in_a_row_each_wait() { + let (mut app, x) = app_with_three_moves(); + let stop = |message: &str| (STOP_STEP.to_string(), json!({"message": message, "allowContinue": false})); + let moved = ("object.move".to_string(), json!({"dx": 1, "dy": 0})); + app.ui.action_sets[0].actions[0].steps = vec![stop("first"), stop("second"), moved, stop("last")]; + let message = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.str("message")); + assert_eq!(play(&mut app, 0, 0).unwrap()["atStop"], json!(true)); + assert_eq!((message(&app).as_deref(), x(&app)), (Some("first"), 0.0)); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap()["atStop"], json!(true)); + assert_eq!((message(&app).as_deref(), x(&app)), (Some("second"), 0.0)); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap()["atStop"], json!(true)); + assert_eq!((message(&app).as_deref(), x(&app)), (Some("last"), 1.0)); + assert_eq!(app.run(CONTINUE_PLAYBACK, json!({})).unwrap(), json!({"results": [], "pending": 0})); + assert!(app.ui.action_playing.is_none(), "after the last stop the playback ends"); + assert_eq!(message(&app), None); + } + + #[test] + fn a_stop_with_an_empty_message_still_waits() { + let (mut app, x) = app_with_three_moves(); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app)); + assert_eq!(x(&app), 1.0); + assert_eq!(open_kind(&app), Some(crate::dialogs::action_stop::MESSAGE_KIND)); + } + + #[test] + fn an_excluded_stop_does_not_hold_and_batch_refuses_an_included_one() { + let (mut app, x) = app_with_a_stop(false); + assert_eq!(crate::dialogs::action_batch::open(&mut app, 0, 0).unwrap_err(), "Batch cannot play an action with a stop"); + assert_eq!(open_kind(&app), None); + app.run(INCLUDE_STEPS, json!({"set": 0, "action": 0, "step": 1, "included": false})).unwrap(); + crate::dialogs::action_batch::open(&mut app, 0, 0).unwrap(); + let d = app.ui.dialog.take().unwrap(); + app.run(INCLUDE_STEPS, json!({"set": 0, "action": 0, "step": 1, "included": true})).unwrap(); + app.ui.dialog = Some(d); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap_err(), "Batch cannot play an action with a stop", "a stop included since it opened"); + app.ui.dialog = None; + app.run(INCLUDE_STEPS, json!({"set": 0, "action": 0, "step": 1, "included": false})).unwrap(); + play(&mut app, 0, 0).unwrap(); + assert!(app.ui.action_playing.is_none()); + assert_eq!(x(&app), 7.0, "the excluded stop does not hold the playback"); + } + + #[test] + fn a_stop_nested_in_a_recorded_batch_holds_too() { + let (mut app, x) = app_with_three_moves(); + let moved = json!({"command": "object.move", "params": {"dx": 1, "dy": 0}}); + let stop = json!({"command": STOP_STEP, "params": {"message": "Inner", "allowContinue": false}}); + app.ui.action_sets[0].actions[0].steps = vec![("command.batch".into(), json!({"commands": [moved, stop, moved]}))]; + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app)); + assert_eq!(x(&app), 1.0); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(x(&app), 2.0); + } + + #[test] + fn a_stop_reached_while_recording_is_recorded() { + let (mut app, _) = app_with_a_stop(false); + app.run(START_RECORDING, json!({})).unwrap(); + play(&mut app, 0, 0).unwrap(); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + let ids: Vec<&str> = recorded.steps.iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["command.batch", STOP_STEP, "command.batch"]); + assert_eq!(recorded.steps[1].1, json!({"message": "Paint it", "allowContinue": false})); + } + + #[test] + fn the_message_waits_for_a_dialog_already_open() { + let (mut app, _) = app_with_a_stop(false); + crate::dialogs::action_playback::open(&mut app); + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app)); + frame(&mut app, 0.0); + assert_eq!(open_kind(&app), Some(crate::dialogs::action_playback::KIND), "the open dialog stays"); + crate::dialogs::cancel(&mut app); + frame(&mut app, 0.0); + assert_eq!(open_kind(&app), Some(crate::dialogs::action_stop::MESSAGE_KIND), "the message shows once it closes"); + } + + #[test] + fn play_on_from_a_stop_uses_the_document_active_then() { + let (mut app, x) = app_with_a_stop(false); + play(&mut app, 0, 0).unwrap(); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert_eq!(x(&app), 7.0); + let (mut app, _) = app_with_a_stop(false); + app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap(); + play(&mut app, 0, 0).unwrap(); + frame(&mut app, 0.0); + frame(&mut app, 0.0); + assert!(waiting_at_stop(&app)); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run(CONTINUE_PLAYBACK, json!({})).unwrap(); + assert!(app.ui.action_playing.is_some(), "a document opened at the stop does not end the playback"); + let bounds = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert_eq!(bounds.x0, 2.0, "the next step runs on the document active now"); + } + + #[test] + fn a_saved_stop_survives_and_older_files_still_load() { + let (mut app, _) = app_with_a_stop(true); + let saved = files::write_set(&app.ui.action_sets[0]); + let file: Value = serde_json::from_str(&saved).unwrap(); + assert_eq!(file["version"], json!(3), "older builds must refuse a stop"); + let steps = file["actions"][0]["steps"].clone(); + assert_eq!(steps[1], json!({"command": "action.stop", "params": {"message": "Paint it", "allowContinue": true}})); + let loaded = files::read_set(&saved, "Test").unwrap(); + assert_eq!(loaded.actions[0].steps, app.ui.action_sets[0].actions[0].steps); + let kept: Action = serde_json::from_value(serde_json::to_value(&loaded.actions[0]).unwrap()).unwrap(); + assert_eq!(kept.steps[1].1, json!({"message": "Paint it", "allowContinue": true}), "the preferences keep it too"); + app.ui.action_sets[0].actions[0].steps.remove(1); + let without = files::write_set(&app.ui.action_sets[0]); + assert_eq!(serde_json::from_str::(&without).unwrap()["version"], json!(1), "a set with no stop stays version 1"); + for version in [1, 2] { + let old = + json!({"format": "vcactions", "version": version, "name": "Old", "actions": [{"name": "A", "steps": [{"command": "object.move"}]}]}); + assert_eq!(files::read_set(&old.to_string(), "Test").unwrap().actions[0].steps.len(), 1, "version {version}"); + } + let later = json!({"format": "vcactions", "version": 4, "name": "New", "actions": []}); + assert_eq!(files::read_set(&later.to_string(), "Test").unwrap_err(), "the action set file needs a later version of VectorCraft"); + for params in [json!({"message": 5}), json!({"allowContinue": "yes"}), json!(42)] { + let stop = json!({"command": "action.stop", "params": params}); + for step in [stop.clone(), json!({"command": "command.batch", "params": {"commands": [stop]}})] { + let bad = json!({"format": "vcactions", "version": 3, "name": "Bad", "actions": [{"name": "A", "steps": [step]}]}); + assert_eq!(files::read_set(&bad.to_string(), "Test").unwrap_err(), "not an action set file", "{step}"); + } + } + let bare = json!({"format": "vcactions", "version": 3, "name": "Bare", "actions": [{"name": "A", "steps": [{"command": "action.stop"}]}]}); + assert!(files::read_set(&bare.to_string(), "Test").is_ok(), "a stop may leave its params out"); + let nested = json!({"commands": [{"command": "action.stop", "params": {"message": "Inner"}}]}); + let set = ActionSet::new("Nested", vec![Action::new("A", vec![("command.batch".into(), nested)])]); + assert_eq!(serde_json::from_str::(&files::write_set(&set)).unwrap()["version"], json!(3), "a stop in a recorded batch counts"); + } + + /// The panel menu's Insert Stop…, with Record Stop's fields as given, and OK. + fn menu_insert_stop(ctx: &egui::Context, app: &mut VectorcraftApp, message: &str, allow_continue: bool) { + use super::super::tests_appearance::{click, frame_events}; + let texts = frame_events(ctx, app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Insert Stop…").map(|(_, r)| *r).expect("Insert Stop… in the menu"); + click(ctx, app, item.center(), menu); + let d = app.ui.dialog.as_mut().expect("Insert Stop… opens Record Stop"); + assert_eq!(d.kind, crate::dialogs::action_stop::RECORD_KIND); + d.fields.insert("message".into(), json!(message)); + d.fields.insert("allowContinue".into(), json!(allow_continue)); + crate::dialogs::confirm(app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes Record Stop"); + } + + #[test] + fn insert_stop_in_the_menu_goes_after_the_selected_step_or_action() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let texts = frame_events(&ctx, &mut app, vec![], show); + let label = text_rect(&texts, "Three Moves"); + click(&ctx, &mut app, label.center(), show); + menu_insert_stop(&ctx, &mut app, "At the end", false); + let ids = |app: &VectorcraftApp| app.ui.action_sets[0].actions[0].steps.iter().map(|(c, _)| c.clone()).collect::>(); + assert_eq!(ids(&app).iter().position(|c| c == STOP_STEP), Some(3), "the action selected: the stop goes at its end"); + assert!(pstate::>(&ctx, "actions-expanded").contains(&app.ui.action_sets[0].actions[0].id), "the action opens to show it"); + + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(s, _)| s == "Stop: At the end"), "the step's row shows the message: {texts:?}"); + let first = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap(); + click(&ctx, &mut app, first.center(), show); + assert_eq!(pstate::>(&ctx, "actions-selected"), Some((0, 0)), "a click on a step selects its action"); + menu_insert_stop(&ctx, &mut app, "After the first", true); + assert_eq!(app.ui.action_sets[0].actions[0].steps[1].1, json!({"message": "After the first", "allowContinue": true})); + + menu_insert_stop(&ctx, &mut app, "Next", false); + assert_eq!(app.ui.action_sets[0].actions[0].steps[2].1["message"], json!("Next"), "the new stop was the selected step"); + + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show); + menu_insert_stop(&ctx, &mut app, "End again", false); + assert_eq!(app.ui.action_sets[0].actions[0].steps.last().unwrap().1["message"], json!("End again"), "a click on the action clears the step"); + } + + #[test] + fn insert_stop_in_the_menu_goes_into_the_recording_and_needs_something_to_go_into() { + use super::super::tests_appearance::{click, frame_events}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Insert Stop…").map(|(_, r)| *r).expect("Insert Stop… in the menu"); + click(&ctx, &mut app, item.center(), menu); + assert!(app.ui.dialog.is_none(), "nothing selected and nothing recording: the item is off"); + + app.run(START_RECORDING, json!({})).unwrap(); + menu_insert_stop(&ctx, &mut app, "While recording", false); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!(recorded.steps, [(STOP_STEP.to_string(), json!({"message": "While recording", "allowContinue": false}))]); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3); + } + + #[test] + fn set_edits_keep_an_open_record_stop_on_its_action() { + let mut app = app_with_sets(); + crate::dialogs::action_stop::open_record(&mut app, Some((1, 0)), None); + app.run(DUPLICATE, json!({"set": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["set"], json!(2), "a copied set above moves it down"); + app.run(DELETE_SETS, json!({"sets": [0]})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["set"], json!(1), "a deleted set above moves it up"); + app.run(DELETE_SETS, json!({"sets": [1]})).unwrap(); + assert_eq!(open_kind(&app), None, "deleting its set closes it"); + } } diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index ead40505f..4e5092cef 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -1,12 +1,14 @@ //! Action set files: the Actions panel menu's Save Actions… and Load Actions…. //! -//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 or 2, "name", +//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1, 2 or 3, "name", //! "actions": [{"name", "steps": [{"command", "params", "excluded"?}], "key"?, "color"?}]}`. Each step is a -//! command id and its params, as `command.batch` takes them. `key` is the action's function key +//! command id and its params, as `command.batch` takes them, or a stop: the command `action.stop` +//! with `{"message", "allowContinue"}`. `key` is the action's function key //! (`{"key": 1 to 15, "cmd", "shift"}`) and `color` its Button Mode color; an action without them, //! or with a value that is not one, has none. `excluded: true` keeps a step but skips it during //! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay -//! excluded steps. Sets without exclusions use version 1, and earlier version 1 files still work. +//! excluded steps, and sets with stops use version 3 so older builds refuse them rather than fail +//! at a stop. Other sets use version 1, and earlier version 1 and 2 files still work. use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -67,6 +69,19 @@ fn is_false(value: &bool) -> bool { !value } +/// The params of the stops in a step: a stop's own, or those of the stops a `command.batch` step +/// holds, which playback runs as its own steps. +fn stops_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { + if command == super::STOP_STEP { + return vec![params]; + } + if command != "command.batch" { + return vec![]; + } + let steps = params.get("commands").and_then(Value::as_array).into_iter().flatten(); + steps.filter(|step| super::is_stop(step)).map(|step| step.get("params").unwrap_or(&Value::Null)).collect() +} + /// `set` as the text of an action set file. pub fn write_set(set: &ActionSet) -> String { let actions = set @@ -84,7 +99,12 @@ pub fn write_set(set: &ActionSet) -> String { color: a.color.clone(), }) .collect(); - let version = if set.actions.iter().any(|a| !a.excluded_steps.is_empty()) { 2 } else { 1 }; + let has_stop = set.actions.iter().flat_map(|a| &a.steps).any(|(command, params)| !stops_in(command, params).is_empty()); + let version = match (has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { + (true, _) => 3, + (false, true) => 2, + (false, false) => 1, + }; let file = SetFile { format: ACTIONS_EXT.into(), version, name: set.name.clone(), actions }; serde_json::to_string_pretty(&file).unwrap_or_default() } @@ -98,7 +118,7 @@ pub fn read_set(text: &str, name: &str) -> Result { if f.format != ACTIONS_EXT { return Err("not an action set file".into()); } - if !matches!(f.version, 1 | 2) { + if !matches!(f.version, 1..=3) { return Err("the action set file needs a later version of VectorCraft".into()); } if f.actions.len() > MAX_SET_ACTIONS { @@ -107,6 +127,10 @@ pub fn read_set(text: &str, name: &str) -> Result { if f.actions.iter().any(|a| a.steps.len() > MAX_ACTION_STEPS) { return Err("an action in the action set file has more than 10,000 steps".into()); } + let mut stops = f.actions.iter().flat_map(|a| &a.steps).flat_map(|s| stops_in(&s.command, &s.params)); + if stops.any(|p| super::stop_params(p).is_err()) { + return Err("not an action set file".into()); + } let actions = f .actions .into_iter() diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index ff05f1c7e..1cb2ede41 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1332,7 +1332,7 @@ fn swatch_button(app: &mut VectorcraftApp, ui: &mut Ui, r: Rect, paint: &Paint, open_popup(app, ui.ctx(), "color"); } egui::Popup::menu(&resp).open_memory((resp.clicked() && !mixer).then_some(egui::SetOpenCommand::Toggle)).show(|ui| { - swatch_picker(app, ui, target); + widgets::menu_scroll(ui, |ui| swatch_picker(app, ui, target)); }); } @@ -1608,18 +1608,20 @@ pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { None => groups.push((g, vec![(id.clone(), label.to_string())])), } } - for (g, items) in groups { - ui.menu_button(tl!(&g), |ui| { - crate::widgets::menu_scroll(ui, |ui| { - for (id, label) in items { - if ui.button(format!("{label}…")).clicked() { - app.run("effect.dialog", json!({"effect": id})).ok(); - ui.close(); + crate::widgets::menu_scroll(ui, |ui| { + for (g, items) in groups { + ui.menu_button(tl!(&g), |ui| { + crate::widgets::menu_scroll(ui, |ui| { + for (id, label) in items { + if ui.button(format!("{label}…")).clicked() { + app.run("effect.dialog", json!({"effect": id})).ok(); + ui.close(); + } } - } + }); }); - }); - } + } + }); } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs index 82042a9a3..12fd3155e 100644 --- a/crates/ui-egui/src/panels/appearance/tests.rs +++ b/crates/ui-egui/src/panels/appearance/tests.rs @@ -1,5 +1,86 @@ +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll, fx_menu_ends}; use super::*; +/// A selected rectangle, for the fill row's swatch picker and the Add Effect button. +fn app_with_a_selected_rectangle() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({ "ids": [id] })).unwrap(); + app +} + +/// The swatch of a fill row at the top of the window, with the size of its click target. +fn swatch_button_at_the_top() -> (impl FnMut(&mut VectorcraftApp, &mut Ui), egui::Vec2) { + let row = Rect::from_min_size(pos2(0.0, 8.0), vec2(400.0, 22.0)); + (move |app: &mut VectorcraftApp, ui: &mut Ui| swatch_button(app, ui, row, &Paint::None, Painted::Item(0)), vec2(22.0, 22.0)) +} + +/// One tile of the swatch picker. +const TILE: egui::Vec2 = vec2(15.5, 15.5); + +/// The picker has one tile per swatch: with enough of them it stops inside a short window and +/// scrolls, its rows stay 12 tiles wide, and a click on the last tile paints with the last swatch. +#[test] +fn a_long_swatch_picker_scrolls_inside_a_short_window() { + const HEIGHT: f32 = 200.0; + let mut app = app_with_a_selected_rectangle(); + for n in 0..150 { + app.run("swatch.new", json!({"name": format!("Long {n}"), "color": format!("#{:06x}", n + 1)})).unwrap(); + } + let doc = &app.session.active().unwrap().doc; + let last_swatch = doc.swatches.iter().chain(doc.swatch_groups.iter().flat_map(|group| &group.swatches)).last().unwrap().paint.clone(); + let (draw, chip) = swatch_button_at_the_top(); + let mut window = PopupWindow::new(&mut app, HEIGHT, draw); + window.click(&mut app, window.clickable(chip)[0].center()); + let tiles = window.clickable(TILE); + assert!(tiles.len() > 150, "one tile per swatch: {}", tiles.len()); + assert_eq!(tiles[11].top(), tiles[0].top(), "12 tiles in a row"); + assert!(tiles[12].top() > tiles[0].top(), "the 13th tile starts the next row"); + assert!(tiles[tiles.len() - 1].bottom() > HEIGHT, "the picker opens at the top of its list"); + window.wheel_down(&mut app, tiles[0].center()); + let tiles = window.clickable(TILE); + let last = tiles[tiles.len() - 1]; + assert!(last.bottom() <= HEIGHT, "the last tile at {last:?} is inside the window"); + window.click(&mut app, last.center()); + let node = first_subject(&app).expect("the rectangle"); + let (Paint::Solid { color, .. }, Paint::Solid { color: last_color, .. }) = (node.appearance.items[0].paint(), &last_swatch) else { + panic!("solid paints") + }; + assert_eq!(color, last_color); +} + +/// A picker with few swatches fits its tiles and the wheel does not move them. +#[test] +fn a_short_swatch_picker_does_not_scroll() { + let mut app = app_with_a_selected_rectangle(); + let (draw, chip) = swatch_button_at_the_top(); + let mut window = PopupWindow::new(&mut app, 900.0, draw); + window.click(&mut app, window.clickable(chip)[0].center()); + let tiles = window.clickable(TILE); + assert!(tiles[tiles.len() - 1].bottom() <= 900.0, "every tile is inside the window"); + window.wheel_down(&mut app, tiles[0].center()); + assert_eq!(window.clickable(TILE), tiles, "a list that fits does not scroll"); +} + +/// The Add New Effect button: the third of the bottom bar's 24 pt buttons. +fn fx_button(window: &PopupWindow) -> egui::Pos2 { + window.clickable(vec2(24.0, 24.0))[2].center() +} + +fn draw_bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui) { + let node = first_subject(app); + bottom(app, ui, node.as_ref(), Sel::None); +} + +/// The Add New Effect popup has one row per effect group, plug-in groups included. +#[test] +fn the_add_effect_popup_scrolls_inside_a_short_window() { + let (first, last) = fx_menu_ends(); + assert_last_row_scrolls_into_view(&mut app_with_a_selected_rectangle(), 150.0, draw_bottom_bar, fx_button, &first, &last); + assert_short_list_does_not_scroll(&mut app_with_a_selected_rectangle(), draw_bottom_bar, fx_button, &first, &last); +} + /// Duplicating rows highlights the copies, each right above its original, as the Layers panel /// does: the active row's copy becomes active, the anchor's copy the anchor. #[test] diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index d02f5f028..9790270a4 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -48,10 +48,15 @@ const ART_OPTIONS_WIDTH: f32 = 370.0; /// less, they show only in the Transform link's popover. const INLINE_FIELDS_WIDTH: f32 = 480.0; +/// The room the Control bar needs from the artboard's name field to Move and Scale Artwork with +/// Artboard shown as their boxes; with less, the name field narrows from 120 to 100 points. +const NAME_FIELD_ROOM: f32 = 330.0; + /// The Control bar while the Artboard tool is in use, in place of the object controls: the active -/// artboard's preset and orientation, New Artboard and Delete Artboard, its name, Artboard Options, -/// Rearrange All Artboards and the Transform link (the Transform panel in a popover), which all fit -/// in the narrowest window; then Move and Scale Artwork with Artboard (the boxes alone, with their +/// artboard's preset and orientation, New Artboard and Delete Artboard, its name (in a narrower +/// field when the bar is short of room), Artboard Options, Rearrange All Artboards and the Transform +/// link (the Transform panel in a popover), which all fit in the narrowest window, left of the bar's +/// panel menu button; then Move and Scale Artwork with Artboard (the boxes alone, with their /// names in tooltips, when the bar is short of room) and, while the bar has room, the reference /// point the Transform panel also uses with the artboard's X and Y, and its W and H with the /// Constrain Width and Height Proportions link. Each control runs the command its Properties panel @@ -77,7 +82,8 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { // The field shows the name as it is (names are never translated). A new name is applied on Enter // or a click elsewhere, and an empty field or Escape keeps the old one, as in the Properties panel. ui.label(egui::RichText::new(tl!("Name:")).size(12.0).color(t.text)); - if let Some(name) = widgets::text_field_named(ui, ("cb-ab", "name"), Some(tl!("Name:")), Some(&ab.name), 120.0, 1).filter(|n| !n.is_empty()) { + let width = if ui.available_width() < NAME_FIELD_ROOM { 100.0 } else { 120.0 }; + if let Some(name) = widgets::text_field_named(ui, ("cb-ab", "name"), Some(tl!("Name:")), Some(&ab.name), width, 1).filter(|n| !n.is_empty()) { app.run("artboard.setProps", json!({"index": i, "name": name})).ok(); } if widgets::icon_button(ui, "dc-artboard-options", tl!("Artboard Options"), false, 24.0).clicked() { diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index c01d82eb1..256e7ceaf 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -849,11 +849,15 @@ fn the_control_bar_shows_the_artboard_tools_controls() { assert!(!has(&texts, "Snapping"), "{texts:?}"); let link = text_rect(&texts, "Transform"); assert!(link.right() < 790.0 && text_rect(&texts, "Artboard 1").right() < link.left(), "{texts:?}"); - // Move and Scale Artwork with Artboard show as their boxes alone, inside the window, and the - // second one turns Scale Artwork with Artboard on. + // Move and Scale Artwork with Artboard show as their boxes alone, inside the window and left of + // the panel menu button at the bar's right end, and the second one turns Scale Artwork with + // Artboard on. assert!(!has(&texts, "Move Artwork with Artboard"), "{texts:?}"); - let boxes = bar_buttons(&ctx, link.right(), 790.0); + let mut boxes = bar_buttons(&ctx, link.right(), 790.0); + let menu = boxes.pop().filter(|r| (r.width() - 16.0).abs() < 0.5 && r.right() > 785.0); + let menu = menu.unwrap_or_else(|| panic!("the panel menu button ends the bar: {boxes:?}")); assert_eq!(boxes.len(), 2, "{boxes:?}"); + assert!(boxes[1].right() <= menu.left(), "{boxes:?} and the panel menu button at {menu:?}"); let scale = |app: &VectorcraftApp| app.session.tool_options()["scaleArt"].as_bool(); assert_eq!(scale(&app), Some(false)); let press = diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 9dd969c5c..9f9c66ba5 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1321,7 +1321,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if app.session.prefs.show_indic_options { let digits = shared(app, |s| s.digits); ui.add_enabled_ui(has, |ui| { - ui.menu_button(format!(" {}", tl!("Digits")), |ui| digits_items(app, ui, digits)); + ui.menu_button(format!(" {}", tl!("Digits")), |ui| widgets::menu_scroll(ui, |ui| digits_items(app, ui, digits))); }); ui.add_enabled_ui(has, |ui| { ui.menu_button(format!(" {}", tl!("Character Direction")), |ui| char_direction_items(app, ui)); diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index 7670d8cdf..9f9b3c1dd 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -64,6 +64,58 @@ fn a_recent_colour_recolours_the_selected_gradient_stop() { assert_eq!(hex, ["#ffffff", "#ff0000"]); } +/// In a narrow panel the Recent Colors chips wrap onto further rows, so all 30 can be clicked. +#[test] +fn all_thirty_recent_colors_wrap_into_a_narrow_panel() { + let mut app = app(); + let hex = |i: u8| Color::rgb8(i * 8, 0, 255 - i * 8).to_hex(); + for i in 0..30u8 { + app.run("paint.setFill", json!({"color": hex(i)})).unwrap(); + } + assert_eq!(app.session.recent_colors.len(), 30); + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(160.0, 2000.0)); + let chips = |app: &mut VectorcraftApp, events: Vec| { + let mut rects = vec![]; + let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut full = ctx.run_ui(input, |ui| { + show(app, ui); + rects = (0..30usize).map(|i| ctx.read_response(ui.scope_id().with(("recent", i))).map(|r| r.rect)).collect(); + }); + full.textures_delta.clear(); + rects + }; + let rects: Vec = chips(&mut app, vec![]).into_iter().map(|r| r.expect("every chip is drawn")).collect(); + for (i, r) in rects.iter().enumerate() { + assert!(r.right() <= screen.right(), "chip {i} fits the panel's width: {r:?}"); + for (j, o) in rects.iter().enumerate().skip(i + 1) { + assert!(!r.intersects(*o), "chips {i} and {j} do not overlap"); + } + } + assert!(rects[29].top() > rects[0].top(), "the chips wrap onto another row"); + // The last chip, the oldest color, applies like the others. + let pos = rects[29].center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + chips(&mut app, vec![egui::Event::PointerMoved(pos), button(true)]); + chips(&mut app, vec![button(false)]); + let Paint::Solid { color, .. } = crate::panels::current_paints(&app).0 else { panic!("a solid fill") }; + assert_eq!(color.to_hex(), hex(0)); +} + +/// With a few recent colors the chips take one row, as tall as before. +#[test] +fn a_short_recent_colors_list_takes_one_row() { + let mut app = app(); + for h in ["#ff0000", "#00ff00", "#0000ff"] { + app.run("paint.setFill", json!({"color": h})).unwrap(); + } + let ctx = egui::Context::default(); + let first = frame_with(&mut app, &ctx, vec![], egui::Modifiers::NONE, ("recent", 0usize)).0.expect("the first chip is drawn"); + let last = frame_with(&mut app, &ctx, vec![], egui::Modifiers::NONE, ("recent", 2usize)).0.expect("the third chip is drawn"); + assert_eq!(first.top(), last.top()); + assert_eq!(last.left() - first.left(), 38.0); +} + /// In a CMYK document the sliders keep the model picked in the panel for a gradient stop and a /// freeform point, as they do for a solid fill. #[test] diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index 28534e443..3ef2fc75e 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -233,7 +233,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let lr = widgets::icon_button(ui, "library", "", limit.is_some(), 24.0); lr.widget_info(|| widgets::icon_button_info(ui.is_enabled(), limit.is_some(), &tip)); let lr = lr.on_hover_text(tip); - egui::Popup::menu(&lr).show(|ui| limit_menu(app, ui)); + egui::Popup::menu(&lr).show(|ui| widgets::menu_scroll(ui, |ui| limit_menu(app, ui))); ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); // Recolor Artwork with the harmony colours as the new colours: the selected art's, or // (without art) the colours themselves, which OK saves as a colour group. diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 631d38ab7..07744bf1b 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -547,26 +547,28 @@ fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), vec2(12.0, 12.0)), if dresp.hovered() { t.text_strong } else { t.icon }); let dresp = dresp.on_hover_text(tl!("Gradient swatches")); egui::Popup::menu(&dresp).show(|ui| { - ui.set_min_width(200.0); - let mut chosen = None; - let swatches = gradient_swatches(app); - if swatches.is_empty() { - widgets::dim_label(ui, tl!("No gradient swatches")); - } - for (name, grad) in &swatches { - // The gradient swatch the gradient was applied from is lit. - if gradient_row(ui, &t, grad, name, g.swatch.as_ref() == Some(name)).clicked() { - chosen = Some(name.clone()); + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(200.0); + let mut chosen = None; + let swatches = gradient_swatches(app); + if swatches.is_empty() { + widgets::dim_label(ui, tl!("No gradient swatches")); } - } - ui.separator(); - if menu_item(ui, tl!("Save to Swatches"), is_grad, false) { - save_to_swatches(app, g); - } - if let Some(name) = chosen { - super::apply_click(app, ui, json!({ "swatch": name })); - ui.close(); - } + for (name, grad) in &swatches { + // The gradient swatch the gradient was applied from is lit. + if gradient_row(ui, &t, grad, name, g.swatch.as_ref() == Some(name)).clicked() { + chosen = Some(name.clone()); + } + } + ui.separator(); + if menu_item(ui, tl!("Save to Swatches"), is_grad, false) { + save_to_swatches(app, g); + } + if let Some(name) = chosen { + super::apply_click(app, ui, json!({ "swatch": name })); + ui.close(); + } + }); }); } diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 65602079b..e27da3cfd 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -1,8 +1,27 @@ use egui::{Event, Modifiers, PointerButton}; use vectorcraft_engine::Session; +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll}; use super::*; +/// The gradient swatches menu has one row per gradient swatch: with enough of them it stops inside a +/// short window and the wheel reaches Save to Swatches. With few of them it does not scroll. +#[test] +fn a_long_gradient_swatch_menu_scrolls_inside_a_short_window() { + let stops = json!([{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]); + let mut app = app(json!({"gradient": {"stops": stops}})); + app.run("swatch.new", json!({"name": "Dusk", "gradient": {"stops": stops}})).unwrap(); + let paint = current(&app).expect("a gradient fill"); + let short = paint.clone(); + let arrow = |window: &PopupWindow| window.clickable(vec2(14.0, 40.0))[0].center(); + let first = gradient_swatches(&app)[0].0.clone(); + assert_short_list_does_not_scroll(&mut app, move |app, ui| thumbnail(app, ui, &short, true), arrow, &first, "Save to Swatches"); + for n in 0..30 { + app.run("swatch.new", json!({"name": format!("Long {n}"), "gradient": {"stops": stops}})).unwrap(); + } + assert_last_row_scrolls_into_view(&mut app, 200.0, move |app, ui| thumbnail(app, ui, &paint, true), arrow, &first, "Save to Swatches"); +} + #[test] fn offsets_and_json() { assert_eq!(x_to_offset(50.0, 0.0, 200.0), 0.25); diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 7c7acbae7..cc0c60986 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -4,7 +4,7 @@ use egui::{Sense, Ui, vec2}; use serde_json::json; use crate::theme::Tokens; -use crate::widgets::menu_item; +use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; /// Undo (negative) / redo (positive) steps to reach row `target` when `current` is the current @@ -87,6 +87,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } } + widgets::divider(ui); + // A click on a row moves to its state at once, so the selected state is the current one. + const NEW_DOCUMENT: &str = "edit.newDocumentFromState"; + let new_document = crate::menus::enabled(app, NEW_DOCUMENT); + ui.horizontal(|ui| { + if widgets::icon_button_enabled(ui, "file-plus", tl!("New Document from State"), false, new_document, 24.0).clicked() { + crate::menus::invoke(app, NEW_DOCUMENT, json!({})); + } + }); } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -303,6 +312,37 @@ mod tests { assert_eq!(app.session.active().unwrap().revision, revision, "a click on the disabled item changes nothing"); } + /// The bottom of the panel's New Document from State button makes a new document, the active + /// one, from the state a click on a row moved to. The source document stays open at that state. + #[test] + fn the_new_document_button_makes_a_document_from_the_selected_state() { + let mut app = app_with_two_rectangles(); + app.session.execute("edit.undo", &json!({})).unwrap(); + let source = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + egui_extras::install_image_loaders(&ctx); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default() + }; + frame(&mut app, vec![]); + let nodes = frame(&mut app, vec![]); + let button = nodes.iter().map(|(_, n)| n).find(|n| n.label() == Some("New Document from State")).expect("the button"); + let at = button.bounds().expect("its bounds"); + let at = egui::pos2((at.x0 + at.x1) as f32 / 2.0, (at.y0 + at.y1) as f32 / 2.0); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(at), press(true), press(false)]); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.active_index(), Some(1), "the new document is the active one"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers[0].children().unwrap().len(), 1, "the art of the selected state"); + assert!(st.is_dirty(), "the new document is unsaved"); + assert!(std::sync::Arc::ptr_eq(&app.session.documents()[0].doc, &source), "the source stays at that state"); + } + /// Set History Limit… opens Preferences at Performance, the page with History States. #[test] fn set_history_limit_opens_the_history_states_preference() { diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 3c878633f..ce5a5955b 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -270,6 +270,21 @@ fn preset_label(name: &str) -> &str { crate::i18n::tr_ctx(crate::i18n::current(), "imageTrace", name) } +/// The buttons above the Image Trace panel's Preset dropdown, in panel order: each one's name and the +/// built-in preset a click chooses. The help page lists these options before Preset +/// (); +/// which preset each one chooses is VectorCraft's own. +fn preset_buttons() -> [(&'static str, &'static str); 6] { + [ + (preset_label("Auto Color"), "Auto Color"), + (tl!("High Color"), "High Fidelity Photo"), + (tl!("Low Color"), "Low Fidelity Photo"), + (tl!("Grayscale"), "Shades of Gray"), + (tl!("Black and White"), "Black and White Logo"), + (preset_label("Outline"), "Outline"), + ] +} + fn preset_list(app: &mut VectorcraftApp, ui: &mut Ui, current: &str) -> Option { let mut chosen = None; let list = presets(app, ui.ctx()); @@ -314,7 +329,7 @@ pub(crate) fn trace_button(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { if main.on_hover_text(tl!("Trace the image with the Default preset")).clicked() { trace_with(app, "Default"); } - if let Some(p) = egui::Popup::menu(&arrow).show(|ui| preset_list(app, ui, "")).and_then(|r| r.inner) { + if let Some(p) = egui::Popup::menu(&arrow).show(|ui| widgets::menu_scroll(ui, |ui| preset_list(app, ui, ""))).and_then(|r| r.inner) { trace_with(app, &p); } } @@ -559,6 +574,23 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let restored = sync_with_object(&mut st, stored, object, &all, &changes); let mut retrace = false; + // A preset button chooses its preset as the Preset dropdown does; the button of the panel's + // preset shows as selected. + ui.horizontal_wrapped(|ui| { + for (label, name) in preset_buttons() { + let selected = st.preset == name; + if cfg!(test) { + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(("trace-preset-button", name)), selected)); + } + if ui.add(egui::Button::selectable(selected, label)).on_hover_text(preset_label(name)).clicked() + && let Some(p) = all.iter().find(|p| p.0 == name) + { + st.preset = name.to_string(); + st.params = p.1.clone(); + retrace = true; + } + } + }); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Preset:")); let manage = 24.0; diff --git a/crates/ui-egui/src/panels/image_trace/tests.rs b/crates/ui-egui/src/panels/image_trace/tests.rs index d946d4bdc..7e797cead 100644 --- a/crates/ui-egui/src/panels/image_trace/tests.rs +++ b/crates/ui-egui/src/panels/image_trace/tests.rs @@ -1,3 +1,4 @@ +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll}; use super::*; use crate::panels::library_panel; use egui::{Event, PointerButton, Pos2, Rect, vec2}; @@ -41,6 +42,30 @@ fn traced_app_with_ink(ink: u8) -> VectorcraftApp { app } +/// The Tracing Presets arrow at the right end of the Image Trace button. +fn tracing_presets_arrow(window: &PopupWindow) -> Pos2 { + let label = window.text("Image Trace").expect("the Image Trace button"); + Pos2::new(TRACE_BUTTON_W - 4.0, label.center().y) +} + +/// The Image Trace button's list of presets grows with saved presets: with enough of them it stops +/// inside a short window and the wheel reaches the last one. With only the built-in presets it does +/// not scroll. +#[test] +fn a_long_tracing_preset_list_scrolls_inside_a_short_window() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let draw = |app: &mut VectorcraftApp, ui: &mut Ui| trace_button(app, ui, TRACE_BUTTON_W); + let built_in = app.session.execute("imageTrace.presets", &json!({})).unwrap(); + let name = |p: &Value| p["name"].as_str().unwrap().to_string(); + let built_in = built_in["presets"].as_array().unwrap(); + let (first, last) = (name(&built_in[0]), name(&built_in[built_in.len() - 1])); + assert_short_list_does_not_scroll(&mut app, draw, tracing_presets_arrow, &first, &last); + for n in 0..30 { + app.session.execute("imageTrace.presets.save", &json!({"name": format!("Saved {n}"), "preset": "Default"})).unwrap(); + } + assert_last_row_scrolls_into_view(&mut app, 200.0, draw, tracing_presets_arrow, &first, "Saved 29"); +} + #[test] fn a_large_image_traces_and_the_status_bar_says_it_is_slower() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); @@ -1192,6 +1217,111 @@ fn the_preset_list_is_read_once_for_each_change_of_the_saved_presets() { assert_eq!((reads(), shown_preset(&ctx)), (start + 4, "D".to_string())); } +/// Choose `preset` from the Preset dropdown, which shows `shown`. +fn choose_from_preset_menu(app: &mut VectorcraftApp, ctx: &egui::Context, shown: &str, preset: &str) { + let texts = frame(app, ctx, vec![]); + let combo = texts.iter().rfind(|(t, _)| t == shown).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{shown}` in {texts:?}")); + click_at(app, ctx, combo); + let texts = frame(app, ctx, vec![]); + let at = texts.iter().rfind(|(t, _)| t == preset).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{preset}` in {texts:?}")); + click_at(app, ctx, at); +} + +/// Which preset buttons showed as selected at the last frame. +fn selected_preset_buttons(ctx: &egui::Context) -> Vec<&'static str> { + preset_buttons() + .into_iter() + .filter(|(_, name)| ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-preset-button", *name)))) == Some(true)) + .map(|(_, name)| name) + .collect() +} + +#[test] +fn each_preset_button_traces_the_selected_object_again_with_its_preset() { + let mut app = traced_app_with_ink(100); + trace_and_open(&mut app, "Default"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![]); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + for (label, preset) in [ + ("Auto Color", "Auto Color"), + ("High Color", "High Fidelity Photo"), + ("Low Color", "Low Fidelity Photo"), + ("Grayscale", "Shades of Gray"), + ("Black and White", "Black and White Logo"), + ("Outline", "Outline"), + ] { + let steps = undo(&app); + click(&mut app, &ctx, label); + frame(&mut app, &ctx, vec![]); + assert_eq!(selected_trace(&app).unwrap().0, preset, "{label}"); + assert_eq!(undo(&app), steps + 1, "{label} traces as one undo step"); + let all = presets(&mut app, &ctx); + assert_eq!(target(&app).2.unwrap()["params"], find_preset(&all, preset).unwrap().1, "{label} traces with the preset's settings"); + assert_eq!((shown_preset(&ctx), selected_preset_buttons(&ctx)), (preset.to_string(), vec![preset]), "{label}"); + } + // Undo returns to the object's previous preset, and the buttons follow it. + app.run("edit.undo", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + assert_eq!(selected_trace(&app).unwrap().0, "Black and White Logo"); + assert_eq!(selected_preset_buttons(&ctx), ["Black and White Logo"]); +} + +#[test] +fn the_selected_preset_button_follows_the_preset_menu() { + let mut app = traced_app_with_ink(100); + trace_and_open(&mut app, "Default"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![]); + assert!(selected_preset_buttons(&ctx).is_empty(), "Default has no button"); + choose_from_preset_menu(&mut app, &ctx, "Default", "Shades of Gray"); + assert_eq!(selected_trace(&app).unwrap().0, "Shades of Gray"); + assert_eq!(selected_preset_buttons(&ctx), ["Shades of Gray"]); + choose_from_preset_menu(&mut app, &ctx, "Shades of Gray", "6 Colors"); + assert_eq!(selected_trace(&app).unwrap().0, "6 Colors"); + assert!(selected_preset_buttons(&ctx).is_empty(), "6 Colors has no button"); + // A changed setting makes the preset Custom, and no button shows as selected. + click(&mut app, &ctx, "Outline"); + frame(&mut app, &ctx, vec![]); + assert_eq!(selected_preset_buttons(&ctx), ["Outline"]); + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Fills"); + frame(&mut app, &ctx, vec![]); + assert_eq!(shown_preset(&ctx), "Custom"); + assert!(selected_preset_buttons(&ctx).is_empty()); +} + +#[test] +fn a_preset_button_with_no_image_selected_changes_the_panel_settings_as_the_preset_menu_does() { + let blank = || { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app + }; + let mut app = blank(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![]); + let steps = app.session.active().unwrap().history.undo.len(); + click(&mut app, &ctx, "High Color"); + frame(&mut app, &ctx, vec![]); + let by_button: TraceUi = pstate(&ctx, "image-trace"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "nothing is traced"); + assert!(target(&app).2.is_none(), "no Image Trace object"); + assert_eq!(selected_preset_buttons(&ctx), ["High Fidelity Photo"]); + + let mut menu_app = blank(); + let menu_ctx = egui::Context::default(); + crate::theme::install_fonts(&menu_ctx); + choose_from_preset_menu(&mut menu_app, &menu_ctx, "Default", "High Fidelity Photo"); + let by_menu: TraceUi = pstate(&menu_ctx, "image-trace"); + assert_eq!(menu_app.session.active().unwrap().history.undo.len(), steps); + assert_eq!((by_button.preset, by_button.params), (by_menu.preset, by_menu.params)); +} + #[test] fn panel_and_menu_draw_headless() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/layers/controls.rs b/crates/ui-egui/src/panels/layers/controls.rs index ab1ca44a2..ca0e9f99e 100644 --- a/crates/ui-egui/src/panels/layers/controls.rs +++ b/crates/ui-egui/src/panels/layers/controls.rs @@ -12,25 +12,27 @@ pub(super) fn filter_button(app: &mut VectorcraftApp, ui: &mut Ui) { let button = widgets::icon_button(ui, "dc-filter", tl!("Filter"), f.is_on(), 24.0); let mut set = None; egui::Popup::menu(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - ui.horizontal(|ui| { - widgets::subheader(ui, tl!("Filter")); - ui.add_space(12.0); - if ui.add_enabled(f.is_on(), egui::Button::new(egui::RichText::new(tl!("Clear All")).size(12.5)).frame(false)).clicked() { - set = Some(json!({"objects": [], "layers": []})); + widgets::menu_scroll(ui, |ui| { + ui.horizontal(|ui| { + widgets::subheader(ui, tl!("Filter")); + ui.add_space(12.0); + if ui.add_enabled(f.is_on(), egui::Button::new(egui::RichText::new(tl!("Clear All")).size(12.5)).frame(false)).clicked() { + set = Some(json!({"objects": [], "layers": []})); + } + }); + widgets::subheader(ui, tl!("By Object")); + for (k, _) in ObjectKind::ALL { + if widgets::check(ui, k.label(), f.objects.contains(&k), true) { + set = Some(json!({"objects": toggled(&ObjectKind::ALL, &f.objects, k)})); + } + } + widgets::subheader(ui, tl!("By Layer")); + for (s, _) in RowState::ALL { + if widgets::check(ui, s.label(), f.layers.contains(&s), true) { + set = Some(json!({"layers": toggled(&RowState::ALL, &f.layers, s)})); + } } }); - widgets::subheader(ui, tl!("By Object")); - for (k, _) in ObjectKind::ALL { - if widgets::check(ui, k.label(), f.objects.contains(&k), true) { - set = Some(json!({"objects": toggled(&ObjectKind::ALL, &f.objects, k)})); - } - } - widgets::subheader(ui, tl!("By Layer")); - for (s, _) in RowState::ALL { - if widgets::check(ui, s.label(), f.layers.contains(&s), true) { - set = Some(json!({"layers": toggled(&RowState::ALL, &f.layers, s)})); - } - } }); if let Some(p) = set { app.run("ui.layersFilter", p).ok(); @@ -245,24 +247,28 @@ pub(super) fn collect_for_export_menu(ui: &mut Ui, enabled: bool, mut run: impl /// the Select menu, then the active document's saved selections: choosing one recalls it. fn save_selection_menu(app: &mut VectorcraftApp, ui: &mut Ui) { ui.menu_button(format!(" {}", tl!("Save Selection")), |ui| { - for (label, id) in [("Save Selection…", "select.save"), ("Update Selection", "select.update"), ("Edit Selection…", "select.editSaved")] { - if widgets::menu_item(ui, label, crate::menus::enabled(app, id), false) { - crate::menus::invoke(app, id, json!({})); - ui.close(); + widgets::menu_scroll(ui, |ui| { + for (label, id) in [("Save Selection…", "select.save"), ("Update Selection", "select.update"), ("Edit Selection…", "select.editSaved")] + { + if widgets::menu_item(ui, label, crate::menus::enabled(app, id), false) { + crate::menus::invoke(app, id, json!({})); + ui.close(); + } } - } - let names: Vec = app.session.active().map(|d| d.doc.saved_selections.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); - if !names.is_empty() { - ui.separator(); - } - for name in names { - if widgets::menu_item_name(ui, &name, true, false) { - let selected = last_selected(app); - app.run("select.recall", json!({ "name": name })).ok(); - see_selection(app, ui, selected); - ui.close(); + let names: Vec = + app.session.active().map(|d| d.doc.saved_selections.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + if !names.is_empty() { + ui.separator(); } - } + for name in names { + if widgets::menu_item_name(ui, &name, true, false) { + let selected = last_selected(app); + app.run("select.recall", json!({ "name": name })).ok(); + see_selection(app, ui, selected); + ui.close(); + } + } + }); }); } diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 33bcbb047..ac92a4279 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -600,27 +600,29 @@ impl Items<'_> { let at = self.group_of(kind, key); let item = json!({ "kind": kind_param(kind), "item": key }); ui.menu_button(format!(" {}", tl!("Move to Group")), |ui| { - if menu_item(ui, "No Group", true, at.is_none()) { - if at.is_some() { - *act = Some(Act::Run("library.moveItem", item.clone())); - } - ui.close(); - } - for (i, g) in self.lib.groups.iter().enumerate() { - if menu_item_name(ui, &g.name, true, at == Some(i)) { - if at != Some(i) { - let mut p = item.clone(); - p["group"] = json!(g.name); - *act = Some(Act::Run("library.moveItem", p)); + widgets::menu_scroll(ui, |ui| { + if menu_item(ui, "No Group", true, at.is_none()) { + if at.is_some() { + *act = Some(Act::Run("library.moveItem", item.clone())); } ui.close(); } - } - ui.separator(); - if menu_item(ui, "New Group…", true, false) { - *act = Some(Act::NewGroup(json!([item]))); - ui.close(); - } + for (i, g) in self.lib.groups.iter().enumerate() { + if menu_item_name(ui, &g.name, true, at == Some(i)) { + if at != Some(i) { + let mut p = item.clone(); + p["group"] = json!(g.name); + *act = Some(Act::Run("library.moveItem", p)); + } + ui.close(); + } + } + ui.separator(); + if menu_item(ui, "New Group…", true, false) { + *act = Some(Act::NewGroup(json!([item]))); + ui.close(); + } + }); }); if menu_item(ui, "Delete", true, false) { *act = Some(Act::Run("library.removeItem", item)); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 7ba7a8781..4607edbf9 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -795,12 +795,14 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { /// The libraries menu (a library panel's library button, the Swatches panel's): every library by /// submenu, the open ones checked, between `K`'s head and tail items. pub(crate) fn library_menu(app: &mut VectorcraftApp, ui: &mut Ui) { - K::menu_head(app, ui); - let shown = open_panels(&app.ui); - if let Some(id) = library_items(ui, &K::list(app), &|id| shown.contains(&panel_id(K::KIND, id))) { - open::(app, &id); - } - K::menu_tail(app, ui); + widgets::menu_scroll(ui, |ui| { + K::menu_head(app, ui); + let shown = open_panels(&app.ui); + if let Some(id) = library_items(ui, &K::list(app), &|id| shown.contains(&panel_id(K::KIND, id))) { + open::(app, &id); + } + K::menu_tail(app, ui); + }); } /// Menu rows for `libs`: the top-level ones, then a submenu per [`LibraryRef::submenu`], those diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index a7fe30185..38f984876 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -2,8 +2,8 @@ //! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects //! its image, Ctrl/Cmd-click adds or removes it, and Shift-click selects the images of the rows //! from the last row clicked without Shift to it (with Ctrl/Cmd, adds them); the buttons Relink, -//! Go To, Update and Edit Original act on the selected rows; Link Info shows the details of the -//! first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: +//! Go To, Update and Edit Original act on the selected rows (Relink through its dialog, with the +//! Replace choice: `ui.relinkDialog`); Link Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: //! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Select Linked / Embedded / Missing / //! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size //! and Show in Folder. @@ -128,16 +128,19 @@ pub(crate) fn updated_status(n: usize) -> String { crate::i18n::msg_n(n as u64, "Updated {n} image", "Updated {n} images", &[]) } -/// Relink images `ids` to a picked file. -pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec) { - crate::picks::button(app, move |app| relink_picked(app, ids.clone())); +/// The status bar's line for `n` relinked images, naming the files `missed` that weren't found. +pub(crate) fn relinked_status_missed(n: usize, missed: &[&str]) -> String { + if missed.is_empty() { + relinked_status(n) + } else { + let (one, other) = ("Relinked {n} image; not in that folder: {names}", "Relinked {n} images; not in that folder: {names}"); + crate::i18n::msg_n(n as u64, one, other, &[("names", &missed.join(", "))]) + } } -fn relink_picked(app: &mut VectorcraftApp, ids: Vec) { - let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; - let Some(path) = crate::picks::open(app, &pick) else { return }; - let r = app.run("links.relink", json!({ "ids": ids, "path": path })); - report(app, r, |v| relinked_status(v["relinked"].as_array().map_or(0, Vec::len))); +/// The ids of the selected image objects and placed documents (Relink's dialog). +pub(crate) fn selected_ids(app: &VectorcraftApp) -> Vec { + selected_images(app) } /// Replace images `ids` with a picked file (Control bar, Properties): it keeps its own aspect @@ -163,13 +166,7 @@ fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec) { let r = app.run("links.relink", json!({ "ids": ids, "folder": folder })); report(app, r, |v| { let missed: Vec<&str> = v["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - let n = v["relinked"].as_array().map_or(0, Vec::len); - if missed.is_empty() { - relinked_status(n) - } else { - let (one, other) = ("Relinked {n} image; not in that folder: {names}", "Relinked {n} images; not in that folder: {names}"); - crate::i18n::msg_n(n as u64, one, other, &[("names", &missed.join(", "))]) - } + relinked_status_missed(v["relinked"].as_array().map_or(0, Vec::len), &missed) }); } @@ -426,7 +423,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { go_to(app, id); } if widgets::icon_button_enabled(ui, "link", tl!("Relink…"), false, !sel.is_empty() && can_pick(app), 24.0).clicked() { - relink(app, ids(&sel, |_| true)); + crate::menus::invoke(app, "ui.relinkDialog", json!({ "ids": ids(&sel, |_| true) })); } if widgets::icon_button_enabled(ui, "link-2-off", tl!("Break Link"), false, !linked.is_empty(), 24.0).clicked() { break_link(app, linked.clone()); @@ -548,7 +545,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let any = !sel.is_empty(); let missing_any = list["missing"].as_u64().unwrap_or(0) > 0; if menu_item(ui, tl!("Relink…"), any && can_pick(app), false) { - relink(app, ids(&sel, |_| true)); + crate::menus::invoke(app, "ui.relinkDialog", json!({ "ids": ids(&sel, |_| true) })); } if menu_item(ui, tl!("Relink to Folder…"), (any || missing_any) && app.services.pick_folder.is_some(), false) { relink_to_folder(app, ids(&sel, |_| true)); diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 1f98c74cd..4b5d4054a 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -217,13 +217,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let sets = offered_sets(&st); ui.add_enabled_ui(!sets.is_empty(), |ui| { ui.menu_button(tl!("Stylistic Sets"), |ui| { - for set in &sets { - let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); - let on = flag.get(&next); - if widgets::check_name(ui, &set_label(set), on) { - change = Some(Change::Toggle(flag, !on)); + widgets::menu_scroll(ui, |ui| { + for set in &sets { + let flag = Flag::Extra(EXTRA_FEATURES[usize::from(set.number - 1)]); + let on = flag.get(&next); + if widgets::check_name(ui, &set_label(set), on) { + change = Some(Change::Toggle(flag, !on)); + } } - } + }); }); }); if cur.ligatures && vectorcraft_text::ligatures_suppressed_by(st.tracking) && !vectorcraft_text::explicit_ligatures(&st.features) { diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 0423bc0c9..a1df5a927 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -599,8 +599,9 @@ const BAR_ALIGNMENTS: [&str; 3] = ["Align left", "Align center", "Align right"]; fn the_control_bar_shows_the_paragraph_alignment_of_the_selected_type() { let ctx = control_bar_ctx(); let mut app = control_bar_app(&[json!({"x": 20, "y": 50, "text": "Point"})]); - // Pinned, so that the room the buttons need can't creep up past common window widths. - for width in [1440.0, 1920.0] { + // Pinned, so that the room the buttons need can't creep up past common window widths. The + // bar's right end keeps the panel menu button's room. + for width in [1536.0, 1920.0] { let nodes = control_bar_nodes(&ctx, &mut app, width, vec![]); let ends = BAR_ALIGNMENTS.map(|n| control_bar_end(&nodes, n)); assert!(ends.iter().all(|e| e.is_some_and(|x| x <= f64::from(width) - 10.0)), "pinned: the buttons show on a {width} pt window: {ends:?}"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 8f3af21b0..3dd7da7d6 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -435,7 +435,7 @@ fn objects_on_path_section(app: &mut VectorcraftApp, ui: &mut Ui, oop: &ObjectsO egui::Grid::new("props-objects-on-path").num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| { dim_label(ui, tl!("Pivot:")); // 9 is no square: the groups' pivots differ. - if let Some(i) = widgets::reference_point(ui, oop.pivot.map_or(9, usize::from)) + if let Some(i) = widgets::reference_point_named(ui, oop.pivot.map_or(9, usize::from), tl!("Pivot:")) && oop.pivot.is_none_or(|p| usize::from(p) != i) { set(app, json!({ "pivot": i })); diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 5e82d51f1..ea1a60095 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -2,6 +2,7 @@ use egui::{Event, PointerButton, Pos2, Rect, vec2}; use serde_json::Value; use vectorcraft_engine::Session; +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, fx_menu_ends}; use super::*; /// One frame of the panel with `events` → the texts painted, with their rects. @@ -30,6 +31,19 @@ fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)] frame(app, ctx, vec![b(false)]); } +/// The Appearance section's fx button opens the Add Effect popup, with one row per effect group, +/// plug-in groups included: in a short window the wheel reaches its last row. +#[test] +fn the_fx_popup_scrolls_inside_a_short_window() { + let (first, last) = fx_menu_ends(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let fx = |window: &PopupWindow| window.text("fx").expect("the fx button").center(); + assert_last_row_scrolls_into_view(&mut app, 190.0, appearance_section, fx, &first, &last); +} + /// One selected clip group has Edit Clipping Path and Edit Contents at the top of the panel, /// under its Clip Group heading and before every section, and they select its clipping path or /// its clipped art; one that clips nothing has Edit Clipping Path alone. A plain group, or a clip diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 1b2ff3559..f42c47470 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -160,6 +160,13 @@ pub(crate) fn changed_instances_selected(app: &VectorcraftApp) -> bool { instance_selected(app, |c| !c.extras().symbol_overrides.is_empty()) } +/// Does the selection hold a symbol instance whose scale, rotation or shear differs from its +/// symbol's, which Reset Transformation restores? +fn transformed_instances_selected(app: &VectorcraftApp) -> bool { + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { return false }; + instance_selected(app, |c| vectorcraft_engine::cmd::reset_transformation_changes(&doc, c)) +} + /// The symbols of the selected objects when every one is a symbol instance; `None` otherwise. fn selected_instance_symbols(app: &VectorcraftApp) -> Option> { let st = app.session.active()?; @@ -178,7 +185,9 @@ fn selected_instance_symbols(app: &VectorcraftApp) -> Option> { /// The Control bar's Replace menu while symbol instances alone are selected: the document's /// symbols, showing the one the instances share (blank when they differ); picking another /// replaces them all, as "Select a new symbol from the Replace dropdown in the Control panel" -/// (). +/// (), +/// and the Reset button, which gives them their symbol's scale, rotation and shear again +/// (). pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(symbols) = selected_instance_symbols(app) else { return }; let Some(st) = app.session.active() else { return }; @@ -199,6 +208,14 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { { app.status(e); } + // Reset: Reset Transformation, enabled while an instance's scale, rotation or shear differs + // from its symbol's. + let reset = transformed_instances_selected(app); + if ui.add_enabled_ui(reset, |ui| widgets::flat_button_fit(ui, tl!("Reset"), 48.0).on_hover_text(tl!("Reset Transformation"))).inner.clicked() + && let Err(e) = app.run("symbol.instance.resetTransform", json!({})) + { + app.status(e); + } ui.add_space(4.0); } @@ -328,7 +345,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Delete Symbol"), sel.is_some() || !marked.is_empty(), false) { delete(app, &marked, sel.as_deref(), false); } - let items: [(&str, bool, &str, Value); 7] = [ + let items: [(&str, bool, &str, Value); 8] = [ (tl!("Symbol Options…"), sel.is_some(), "ui.symbolOptions", json!({"edit": true, "name": sel})), // The selected instance, else the symbol selected in the panel. (tl!("Edit Symbol"), inst || sel.is_some(), "symbol.edit", if inst { json!({}) } else { name.clone() }), @@ -336,6 +353,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { (tl!("Replace Symbol"), inst && sel.is_some(), "symbol.replace", name.clone()), (tl!("Break Link to Symbol"), inst, "symbol.breakLink", json!({})), (tl!("Reset Symbol Instance"), changed_instances_selected(app), "symbol.instance.reset", json!({})), + (tl!("Reset Transformation"), transformed_instances_selected(app), "symbol.instance.resetTransform", json!({})), (tl!("Select All Instances"), sel.is_some(), "symbol.selectInstances", name), ]; for (label, enabled, cmd, params) in items { @@ -1300,4 +1318,64 @@ mod tests { let doc = &app.session.active().unwrap().doc; assert!(matches!(&doc.node(leaf).unwrap().kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Star"), "the instance is replaced"); } + + /// The panel menu's Reset Transformation and the Control bar's Reset are enabled only while a + /// selected instance's scale, rotation or shear differs from its symbol's, and give the + /// instance its symbol's again. + #[test] + fn reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it() { + let mut app = leaf_and_dot(); + let leaf = instance_of(&app, "Leaf").expect("a Leaf instance"); + app.run("select.set", json!({"ids": [leaf.0]})).unwrap(); + let xf = |app: &VectorcraftApp| match app.session.active().unwrap().doc.node(leaf).map(|n| &n.kind) { + Some(NodeKind::SymbolInstance { xf, .. }) => *xf, + _ => panic!("the Leaf instance"), + }; + let master = xf(&app); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let item_disabled = |app: &mut VectorcraftApp| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 400.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| menu(app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + nodes + .iter() + .find(|(_, n)| n.label().is_some_and(|l| l.trim() == "Reset Transformation")) + .map(|(_, n)| n.is_disabled()) + .expect("the menu item") + }; + // At its symbol's scale the menu item is disabled and a click changes nothing. + assert!(item_disabled(&mut app), "the item is disabled at the symbol's scale"); + let before = app.session.active().unwrap().doc.clone(); + click_menu(&ctx, &mut app, "Reset Transformation"); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc), "the item is disabled"); + app.run("object.rotate", json!({"angle": 30})).unwrap(); + assert!(!item_disabled(&mut app), "a turned instance enables it"); + click_menu(&ctx, &mut app, "Reset Transformation"); + let close = + |a: vectorcraft_geom::Affine, b: vectorcraft_geom::Affine| a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| (x - y).abs() < 1e-9); + assert!(close(xf(&app), master), "the menu item resets the instance: {:?} vs {master:?}", xf(&app)); + // The Control bar's Reset. + let bar = |app: &mut VectorcraftApp, events: Vec| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.horizontal(|ui| control_bar(app, ui)); + }); + out.textures_delta.clear(); + out + }; + let reset_disabled = |out: &egui::FullOutput| { + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + nodes.iter().find(|(_, n)| n.label().is_some_and(|l| l.trim() == "Reset")).map(|(_, n)| n.is_disabled()).expect("a Reset button") + }; + assert!(reset_disabled(&bar(&mut app, vec![])), "Reset is disabled at the symbol's scale"); + app.run("object.scale", json!({"sx": 150, "sy": 50})).unwrap(); + let out = bar(&mut app, vec![]); + assert!(!reset_disabled(&out)); + let at = clickable(&out).into_iter().find(|(l, _)| l == "Reset").expect("a Reset button").1; + bar(&mut app, vec![egui::Event::PointerMoved(at), button(at, true), button(at, false)]); + assert!(close(xf(&app), master), "the Control bar's Reset resets the instance: {:?} vs {master:?}", xf(&app)); + } } diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs index b5e8ed7f7..899d335fa 100644 --- a/crates/ui-egui/src/panels/tests_constrain.rs +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -297,10 +297,10 @@ fn the_link_is_a_preference_agents_can_read_and_set() { assert_eq!(size(&app), (200.0, 100.0)); } -/// One Tab moves from a field to the next field: a field's label (its scrub zone) is not a tab -/// stop of its own, and the W/H link between W and H, which is still focusable, comes after the -/// fields. In the Transform panel and the Properties panel the fields run X, W, Y, H (a grid, two -/// to a row); in the Control bar X, Y, W, H. +/// One Tab moves from a field to the next field: a field's label (its scrub zone), spinner and +/// preset chevron are not tab stops of their own, and the W/H link between W and H, which is +/// still focusable, comes after the fields. In the Transform panel and the Properties panel the +/// fields run X, W, Y, H (a grid, two to a row); in the Control bar X, Y, W, H. #[test] fn one_tab_moves_from_a_field_to_the_next() { // From X (the selection's centre, 60 pt) the next field is W in the panels and Y in the bar. @@ -328,6 +328,12 @@ fn one_tab_moves_from_a_field_to_the_next() { tab_then_type(&mut app, crate::chrome::control_bar, "400 pt", "200"); let r = app.session.doc().unwrap().doc.artboards[0].rect; assert_eq!((r.width().round(), r.height().round()), (400.0, 200.0), "Artboard Control bar: Tab went from W to H"); + // The Transform panel's Rotate and Shear fields each have a spinner and presets: Tab goes from + // Rotate straight to Shear, past Rotate's preset chevron and Shear's spinner. + let mut app = rect_app(); + tab_then_type(&mut app, transform::show, "0°", "30"); + let shear = app.session.active().map(|st| st.doc.bounds_of(&st.selection.objects, false).map(|b| (b.width().round(), b.height().round()))); + assert_eq!(shear, Some(Some((129.0, 50.0))), "Transform panel: Tab went from Rotate to Shear"); // Shift+Tab goes back one field, one press too: from W to X in the panels. for (name, draw) in [("Transform panel", transform::show as Draw), ("Properties", properties::transform_section as Draw)] { let mut app = rect_app(); diff --git a/crates/ui-egui/src/panels/tests_long_popups.rs b/crates/ui-egui/src/panels/tests_long_popups.rs new file mode 100644 index 000000000..8597ea995 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_long_popups.rs @@ -0,0 +1,147 @@ +//! A headless window for checking that a panel's popup with no fixed length stays inside the window. + +use egui::{Event, PointerButton, Pos2, Rect, Ui, vec2}; + +use super::VectorcraftApp; + +/// The frames that opening a popup takes. +const OPEN_FRAMES: usize = 8; +/// The frames that finishing a wheel's scrolling takes. +const SCROLL_FRAMES: usize = 40; +/// The window's width, wider than every popup. +const WIDTH: f32 = 500.0; +/// A window tall enough for every short list. +const TALL: f32 = 900.0; + +/// What a window draws each frame. +type Draw<'a> = Box; + +/// A window `height` pt tall with its own clock, drawing `draw` each frame. +pub(super) struct PopupWindow<'a> { + ctx: egui::Context, + time: f64, + height: f32, + draw: Draw<'a>, + texts: Vec<(String, Rect)>, +} + +impl<'a> PopupWindow<'a> { + pub(super) fn new(app: &mut VectorcraftApp, height: f32, draw: impl FnMut(&mut VectorcraftApp, &mut Ui) + 'a) -> Self { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut window = Self { ctx, time: 0.0, height, draw: Box::new(draw), texts: vec![] }; + window.frames(app, 2); + window + } + + /// One frame with `events`. + fn frame(&mut self, app: &mut VectorcraftApp, events: Vec) { + self.time += 1.0 / 60.0; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(WIDTH, self.height))); + let input = egui::RawInput { time: Some(self.time), screen_rect, events, ..Default::default() }; + let draw = &mut self.draw; + let mut out = self.ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); + self.texts = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + } + + fn frames(&mut self, app: &mut VectorcraftApp, count: usize) { + for _ in 0..count { + self.frame(app, vec![]); + } + } + + /// Where the last frame drew the text `wanted`: the last one drawn, so a popup's row comes + /// before a panel's text of the same name. + pub(super) fn text(&self, wanted: &str) -> Option { + self.texts.iter().rfind(|(text, _)| text.trim() == wanted).map(|(_, rect)| *rect) + } + + /// The rects of the last frame's clickable widgets `size` across, in the order they were drawn. + pub(super) fn clickable(&self, size: egui::Vec2) -> Vec { + self.ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.size() == size) + .map(|w| w.rect) + .collect() + }) + } + + /// A click at `at`, and the frames a popup takes to open. + pub(super) fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) { + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + self.frame(app, vec![Event::PointerMoved(at), button(true)]); + self.frame(app, vec![button(false)]); + self.frames(app, OPEN_FRAMES); + } + + /// Turns the wheel far down with the pointer at `over`, and lets the scrolling finish. + pub(super) fn wheel_down(&mut self, app: &mut VectorcraftApp, over: Pos2) { + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -5000.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + self.frame(app, vec![Event::PointerMoved(over), wheel]); + self.frames(app, SCROLL_FRAMES); + } +} + +/// The Add Effect popup's first and last rows with no plug-ins: the effect catalog's groups, in order. +pub(super) fn fx_menu_ends() -> (String, String) { + let mut groups: Vec<&str> = vec![]; + let catalog = vectorcraft_effects::effect_catalog(); + for group in catalog.iter().map(|effect| effect.menu.get(1).copied().unwrap_or("Other")) { + if !groups.contains(&group) { + groups.push(group); + } + } + (groups[0].to_string(), groups[groups.len() - 1].to_string()) +} + +/// In a window `height` pt tall, the popup of the button `pick` finds opens at its row `first`, and +/// the wheel brings its row `last` into the window. +pub(super) fn assert_last_row_scrolls_into_view( + app: &mut VectorcraftApp, + height: f32, + draw: impl FnMut(&mut VectorcraftApp, &mut Ui), + pick: impl FnOnce(&PopupWindow) -> Pos2, + first: &str, + last: &str, +) { + let mut window = PopupWindow::new(app, height, draw); + let button = pick(&window); + window.click(app, button); + let over = window.text(first).unwrap_or_else(|| panic!("the popup's first row, {first}")).center(); + assert!(window.text(last).is_none_or(|row| row.bottom() > height), "the popup opens at the top of its list, with {last} out of view"); + window.wheel_down(app, over); + let row = window.text(last).unwrap_or_else(|| panic!("the wheel brings {last} into the {height} pt window")); + assert!(row.bottom() <= height, "{last} at {row:?} is inside the {height} pt window"); +} + +/// In a tall window the popup of the button `pick` finds shows its rows `first` to `last` without +/// scrolling, and the wheel moves neither. +pub(super) fn assert_short_list_does_not_scroll( + app: &mut VectorcraftApp, + draw: impl FnMut(&mut VectorcraftApp, &mut Ui), + pick: impl FnOnce(&PopupWindow) -> Pos2, + first: &str, + last: &str, +) { + let mut window = PopupWindow::new(app, TALL, draw); + let button = pick(&window); + window.click(app, button); + let shown = |window: &PopupWindow| { + let (top, end) = (window.text(first).expect("the first row"), window.text(last).expect("the last row")); + assert!(end.bottom() <= TALL, "{last} at {end:?} is inside the window"); + (top, end) + }; + let before = shown(&window); + window.wheel_down(app, before.0.center()); + assert_eq!(shown(&window), before, "a list that fits does not scroll"); +} diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs new file mode 100644 index 000000000..3c94bf88a --- /dev/null +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -0,0 +1,411 @@ +//! The 3D and Materials panel drawn headless: its Window menu entry, the object types applying or +//! changing a 3D effect, the dialogs' controls for the applied effect, and one undo step for a +//! drag or a typed value. +#![allow(clippy::unwrap_used, clippy::panic)] + +use serde_json::{Map, Value, json}; +use vectorcraft_engine::Session; + +use super::{ID, Target, changed_settings, send, set_type, show, target}; +use crate::tests_labels::painted_text; +use crate::{Services, VectorcraftApp}; + +/// An app with a selected blue square. +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + app.run("paint.setFill", json!({"color": "#338ad6"})).unwrap(); + app +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +fn effect(app: &VectorcraftApp) -> (String, Value) { + let t = target(app).unwrap(); + (t.effect, t.params) +} + +#[test] +fn the_window_menu_opens_the_panel() { + let mut app = app(); + assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "3D and Materials")); + app.run("window.panel", json!({"panel": ID})).unwrap(); + assert!(crate::panel_docking::showing(&app.ui, ID)); + app.run("window.panel", json!({"panel": "3D and Materials"})).unwrap(); +} + +#[test] +fn without_a_3d_effect_the_panel_offers_the_object_types() { + let mut app = app(); + let text = painted_text(&mut app, show); + for label in ["Plane", "Extrude", "Revolve", "Inflate", "Select objects, then pick an object type"] { + assert!(text.contains(label), "{label} in {text}"); + } + app.run("select.none", json!({})).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains("Select objects, then pick an object type"), "{text}"); +} + +#[test] +fn an_object_type_applies_its_effect_then_changes_it_in_place() { + let mut app = app(); + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(effect(&app).0, "threeD.extrude"); + assert_eq!(undo_len(&app), history + 1); + app.run("effect.setParams", json!({"index": 0, "item": null, "params": {"rotationX": 15.0, "depth": 70.0}})).unwrap(); + for (kind, id) in [("inflate", "threeD.inflate"), ("revolve", "threeD.revolve"), ("plane", "threeD.rotate"), ("extrude", "threeD.extrude")] { + let history = undo_len(&app); + set_type(&mut app, kind).unwrap(); + let (now, params) = effect(&app); + assert_eq!(now, id); + assert_eq!(params["rotationX"], 15.0, "{kind}"); + assert_eq!(undo_len(&app), history + 1, "{kind}"); + // The object keeps one effect: the type changed in place. + assert_eq!(app.session.active().unwrap().doc.node(app.session.active().unwrap().selection.objects[0]).unwrap().appearance.effects.len(), 1); + } + // The type it already has runs nothing. + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(undo_len(&app), history); + assert!(set_type(&mut app, "cube").unwrap_err().contains("must be one of plane, extrude, revolve, inflate")); + assert_eq!(undo_len(&app), history); +} + +/// The ids of the effects of objects `ids`, in order. +fn effect_ids(app: &VectorcraftApp, ids: &[u64]) -> Vec> { + let st = app.session.active().unwrap(); + ids.iter().map(|id| st.doc.node(vectorcraft_doc::NodeId(*id)).unwrap().appearance.effects.iter().map(|e| e.id.clone()).collect()).collect() +} + +#[test] +fn an_object_type_reaches_every_selected_object_in_one_undo_step() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.inflate", "item": null})).unwrap(); + let c = app.run("shape.rectangle", json!({"x": 100, "y": 250, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + // The first object already has the type; the second has another; the third has none. + for order in [[a, b, c], [c, b, a]] { + app.run("select.set", json!({"ids": order})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(effect_ids(&app, &[a, b, c]), vec![vec!["threeD.extrude".to_string()]; 3], "{order:?}"); + assert_eq!(undo_len(&app), history + 1, "{order:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before, "{order:?}"); + } + // Every selected object already has the type: nothing is recorded. + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + set_type(&mut app, "extrude").unwrap(); + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(undo_len(&app), history); +} + +#[test] +fn a_drag_ends_when_the_selection_goes_away() { + let mut app = app(); + set_type(&mut app, "extrude").unwrap(); + let ctx = egui::Context::default(); + let history = undo_len(&app); + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(90.0))]), true); + assert!(app.session.in_interaction()); + app.session.active_mut().unwrap().selection.objects.clear(); + ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)).textures_delta.clear(); + assert!(!app.session.in_interaction()); + assert_eq!(undo_len(&app), history + 1); +} + +#[test] +fn with_nothing_selected_an_object_type_changes_nothing() { + let mut app = app(); + app.run("select.none", json!({})).unwrap(); + let history = undo_len(&app); + assert!(set_type(&mut app, "plane").is_err()); + assert_eq!(undo_len(&app), history); +} + +#[test] +fn the_panel_shows_the_dialog_controls_of_each_type() { + let mut app = app(); + for (kind, label) in [("extrude", "Depth"), ("inflate", "Volume"), ("revolve", "Revolve angle"), ("plane", "Rotation X")] { + set_type(&mut app, kind).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains(label), "{kind}: {label} in {text}"); + assert!(text.contains("Lighting"), "{kind}: {text}"); + // Drawing the panel is no edit. + let history = undo_len(&app); + painted_text(&mut app, show); + assert_eq!(undo_len(&app), history, "{kind}"); + } + // The dialog's artboard-axis toggle has no artboard gizmo in the panel. + set_type(&mut app, "revolve").unwrap(); + assert!(!painted_text(&mut app, show).contains("Show axis")); +} + +#[test] +fn a_drag_previews_and_records_one_undo_step_when_released() { + let mut app = app(); + set_type(&mut app, "extrude").unwrap(); + let ctx = egui::Context::default(); + let history = undo_len(&app); + for depth in [60.0, 80.0, 120.0] { + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(depth))]), true); + assert_eq!(effect(&app).1["depth"], depth); + } + // Another setting in the same drag keeps the earlier one. + send(&mut app, &ctx, Map::from_iter([("twist".to_string(), json!(45.0))]), true); + assert_eq!(effect(&app).1["depth"], 120.0); + assert_eq!(undo_len(&app), history); + // Released without a change in the last frame: the drag commits. + send(&mut app, &ctx, Map::new(), false); + assert_eq!(undo_len(&app), history + 1); + let (_, params) = effect(&app); + assert_eq!((params["depth"].clone(), params["twist"].clone()), (json!(120.0), json!(45.0))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(effect(&app).1["depth"], 50.0); + // A typed value is one undo step at once. + send(&mut app, &ctx, Map::from_iter([("taper".to_string(), json!(60.0))]), false); + assert_eq!(effect(&app).1["taper"], 60.0); + assert_eq!(undo_len(&app), history + 1); + // Nothing changed and no drag: nothing runs. + send(&mut app, &ctx, Map::new(), false); + assert_eq!(undo_len(&app), history + 1); +} + +#[test] +fn a_change_reaches_each_selected_3d_effect_and_no_other_effect() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + // A second object with Inflate after a drop shadow, a third with only a drop shadow. + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.inflate", "item": null})).unwrap(); + let c = app.run("shape.rectangle", json!({"x": 100, "y": 250, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + let shadow = |app: &VectorcraftApp, id: u64| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params.clone() + }; + let (b_shadow, c_shadow) = (shadow(&app, b), shadow(&app, c)); + let history = undo_len(&app); + let ctx = egui::Context::default(); + // `bevel` is an Extrude & Bevel setting only; `depth` both effects take. + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(75.0)), ("bevel".to_string(), json!("classic"))]), true); + send(&mut app, &ctx, Map::new(), false); + assert_eq!(undo_len(&app), history + 1); + let st = app.session.active().unwrap(); + let fx = |id: u64| st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects.clone(); + assert_eq!(fx(a)[0].params["depth"], 75.0); + assert_eq!(fx(a)[0].params["bevel"], "classic"); + assert_eq!(fx(b)[1].params["depth"], 75.0); + assert!(fx(b)[1].params.get("bevel").is_none()); + assert_eq!(fx(b)[0].params, b_shadow); + assert_eq!(fx(c)[0].params, c_shadow); +} + +#[test] +fn the_panel_finds_a_3d_effect_on_a_fill() { + let mut app = app(); + app.run("effect.apply", json!({"effect": "threeD.inflate", "item": 0})).unwrap(); + let t = target(&app).unwrap(); + assert_eq!((t.item, t.index, t.effect.as_str()), (Some(0), 0, "threeD.inflate")); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(target(&app).map(|t: Target| (t.item, t.effect)), Some((Some(0), "threeD.extrude".to_string()))); +} + +#[test] +fn settings_the_selected_effects_disagree_on_are_unset_so_a_reset_reaches_them_all() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"depth": 80.0}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let mut fields = target(&app).map(|t| vectorcraft_effects::merged_params(&t.effect, &t.params)).unwrap().as_object().cloned().unwrap(); + super::mark_mixed(&app, &mut fields); + assert_eq!(fields["depth"], Value::Null); + assert_eq!(fields["twist"], 0.0); + // Resetting Depth to its default (the first object's value) is a change for both objects. + let before = Value::Object(fields.clone()); + fields.insert("depth".into(), json!(50.0)); + let changed = changed_settings(&before, &Value::Object(fields)); + assert_eq!(Value::Object(changed.clone()), json!({"depth": 50.0})); + send(&mut app, &egui::Context::default(), changed, false); + let st = app.session.active().unwrap(); + for id in [a, b] { + assert_eq!(st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["depth"], 50.0); + } +} + +#[test] +fn a_light_list_edit_reaches_only_the_objects_with_the_same_lights() { + let mut app = app(); + let two = json!([{"color": "#ff8040"}, {"color": "#4080ff", "rotation": -35.0}]); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": two}})).unwrap(); + let a = app.session.active().unwrap().selection.objects[0].0; + let add = |app: &mut VectorcraftApp, x: f64, lights: Value| { + let id = app.run("shape.rectangle", json!({"x": x, "y": 250, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": lights}})).unwrap(); + id + }; + let b = add(&mut app, 50.0, json!([{"color": "#00ff00"}])); + let c = add(&mut app, 200.0, two.clone()); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + let lights = |app: &VectorcraftApp, id: u64| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["lights"].clone() + }; + let b_before = lights(&app, b); + let three = json!([{"color": "#ff8040"}, {"color": "#4080ff", "rotation": -35.0}, {"color": "#ffffff", "rotation": 0.0, "height": 90.0}]); + let history = undo_len(&app); + send(&mut app, &egui::Context::default(), Map::from_iter([("lights".to_string(), three.clone())]), false); + assert_eq!(undo_len(&app), history + 1); + assert_eq!(lights(&app, a).as_array().map(Vec::len), Some(3)); + assert_eq!(lights(&app, c), lights(&app, a)); + assert_eq!(lights(&app, b), b_before); +} + +#[test] +fn a_drag_keeps_its_objects_and_a_new_selection_starts_a_new_step() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0]; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let b = vectorcraft_doc::NodeId(b); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null})).unwrap(); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + let history = undo_len(&app); + let ctx = egui::Context::default(); + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(90.0))]), true); + // The selection changes during the drag: the drag on `a` is committed, and `b` starts its own. + app.session.active_mut().unwrap().selection.objects = vec![b]; + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(70.0))]), true); + send(&mut app, &ctx, Map::new(), false); + let depth = |app: &VectorcraftApp, id| app.session.active().unwrap().doc.node(id).unwrap().appearance.effects[0].params["depth"].clone(); + assert_eq!(depth(&app, a), 90.0); + assert_eq!(depth(&app, b), 70.0); + assert_eq!(undo_len(&app), history + 2); +} + +#[test] +fn a_light_drag_keeps_the_objects_whose_lights_matched_when_it_started() { + let mut app = app(); + let one = |color: &str| json!([{"color": color}]); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": one("#ff0000")}})).unwrap(); + let a = app.session.active().unwrap().selection.objects[0].0; + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": one("#00ff00")}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + // The first object's list passes through the second's value during the drag. + send(&mut app, &ctx, Map::from_iter([("lights".to_string(), one("#00ff00"))]), true); + send(&mut app, &ctx, Map::from_iter([("lights".to_string(), one("#0000ff"))]), true); + send(&mut app, &ctx, Map::new(), false); + let lights = + |id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["lights"].clone(); + assert_eq!(lights(a), one("#0000ff")); + assert_eq!(lights(b), one("#00ff00")); +} + +#[test] +fn a_drag_ends_when_the_panel_is_no_longer_drawn() { + let mut app = app(); + set_type(&mut app, "extrude").unwrap(); + let ctx = egui::Context::default(); + let history = undo_len(&app); + ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)).textures_delta.clear(); + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(90.0))]), true); + // A pass that draws the panel keeps the drag; the first pass that does not draw it ends it. + ctx.run_ui(egui::RawInput::default(), |ui| { + // Drawn with the pointer still down: only the mark `show` leaves. + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + super::end_stale_drag(&mut app, ui.ctx()); + }) + .textures_delta + .clear(); + assert!(app.session.in_interaction()); + ctx.run_ui(egui::RawInput::default(), |ui| super::end_stale_drag(&mut app, ui.ctx())).textures_delta.clear(); + assert!(!app.session.in_interaction()); + assert_eq!(undo_len(&app), history + 1); + assert_eq!(effect(&app).1["depth"], 90.0); +} + +/// The panel's controls, drawn once with the accessibility tree on. +fn controls(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default() +} + +fn named<'a>(nodes: &'a [egui::accesskit::Node], label: &str) -> &'a egui::accesskit::Node { + nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no {label:?}")) +} + +/// A checkbox setting the selected effects hold with different values shows a dash, leaves the +/// controls it guards available, and another change leaves it as each effect has it. +#[test] +fn a_mixed_checkbox_shows_a_dash_and_another_change_keeps_each_effects_value() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"shade": false}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Lighting").center(), show); + let nodes = controls(&mut app, &ctx); + assert_eq!(named(&nodes, "Shading").toggled(), Some(egui::accesskit::Toggled::Mixed), "a mixed Shading shows a dash"); + let behind = named(&nodes, "Light behind the object"); + assert!(!behind.is_disabled(), "a mixed Shading leaves the light settings available"); + assert!(!named(&nodes, "Add Light").is_disabled()); + let at = behind.bounds().map(|r| egui::pos2(((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)).unwrap(); + let history = undo_len(&app); + click(&ctx, &mut app, at, show); + assert_eq!(undo_len(&app), history + 1); + let params = |id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params.clone(); + let merged = |id: u64| vectorcraft_effects::merged_params("threeD.extrude", ¶ms(id)); + assert_eq!((merged(a)["shade"].clone(), merged(b)["shade"].clone()), (json!(true), json!(false))); + assert_eq!((merged(a)["lightBehind"].clone(), merged(b)["lightBehind"].clone()), (json!(true), json!(true))); + // A single light whose settings differ: Add Light is off. + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("effect.setParams", json!({"index": 0, "item": null, "params": {"lightIntensity": 40.0}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert!(named(&controls(&mut app, &ctx), "Add Light").is_disabled()); +} + +/// Revolve's Cap checkbox shows a dash when the selected effects disagree on it. +#[test] +fn a_mixed_revolve_cap_shows_a_dash() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "revolve").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.revolve", "item": null, "params": {"cap": false}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let history = undo_len(&app); + assert_eq!(named(&controls(&mut app, &ctx), "Cap").toggled(), Some(egui::accesskit::Toggled::Mixed)); + assert_eq!(undo_len(&app), history); +} + +#[test] +fn changed_settings_lists_only_new_values() { + let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); + let after = json!({"depth": 60.0, "cap": true, "bevel": "classic", "twist": 0.0}); + let changed = changed_settings(&before, &after); + assert_eq!(Value::Object(changed), json!({"depth": 60.0, "bevel": "classic", "twist": 0.0})); + assert!(changed_settings(&after, &after).is_empty()); +} diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 8aaff6849..eb2908400 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -10,7 +10,7 @@ use std::cell::RefCell; use egui::{Color32, Sense, Ui, vec2}; use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; -use vectorcraft_engine::cmd::textstyles; +use vectorcraft_engine::cmd::{fileio, textstyles}; use super::character::{end_typing, text_editing, text_style}; use super::memo::Memo; @@ -339,7 +339,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { /// The panel menu. Select All Unused highlights the styles no text uses (`charStyle.unused`, /// `paraStyle.unused`), Duplicate copies every highlighted style and highlights the copies, and -/// Delete deletes every highlighted style but the Normal style. +/// Delete deletes every highlighted style but the Normal style. Load Character Styles… (Load +/// Paragraph Styles…) and Load All Styles… add the styles of a document picked in an open dialog +/// ([`load`]); they are off without an open document or an open dialog. pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let all = styles(app, kind); let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect(); @@ -394,6 +396,37 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok(); } } + ui.separator(); + // As commands, so a file dialog shown off the UI thread loads the styles when it answers. + let can_load = app.session.active().is_some() && app.services.pick_open.is_some(); + let one = if kind == Kind::Char { + (tl!("Load Character Styles…"), "ui.loadCharStyles") + } else { + (tl!("Load Paragraph Styles…"), "ui.loadParaStyles") + }; + for (label, cmd) in [one, (tl!("Load All Styles…"), "ui.loadAllStyles")] { + if menu_item(ui, label, can_load, false) + && let Err(e) = app.run(cmd, json!({})) + { + app.status(e); + } + } +} + +/// Load Character Styles…, Load Paragraph Styles… and Load All Styles… (`ui.loadCharStyles`, +/// `ui.loadParaStyles`, `ui.loadAllStyles`): the engine command `load` (`charStyle.load`, +/// `paraStyle.load`, `textStyle.loadAll`) on the document at `path`, else on one picked in an open +/// dialog. A cancelled dialog loads nothing. +pub(crate) fn load(app: &mut VectorcraftApp, load: &str, path: Option) -> Result { + let path = match path { + Some(path) => path, + None => { + let pick = crate::FilePick { filters: crate::FilePick::filters(fileio::open_filters()), ..Default::default() }; + let Some(path) = crate::picks::open(app, &pick) else { return Ok(Value::Null) }; + path + } + }; + app.run(load, json!({ "path": path })) } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index 144e2d525..f3218bc35 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -766,6 +766,44 @@ fn dragging_a_highlighted_style_carries_every_highlighted_style() { } } +/// The panel menu's Load Character Styles… (Load Paragraph Styles…) adds that kind of style from +/// the document picked in the open dialog, and Load All Styles… adds both kinds, each in one undo +/// step. Without an open dialog the items are off. +#[test] +fn panel_menus_load_the_styles_of_a_picked_document() { + let mut src = vectorcraft_engine::Session::new(); + src.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Lead", "attrs": {"size": 30.0}})).unwrap(); + src.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center"}})).unwrap(); + let dir = std::env::temp_dir().join(format!("vc-panel-load-styles-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Styles.vectorcraft"); + std::fs::write(&path, vectorcraft_format::save(&src.doc().unwrap().doc, false)).unwrap(); + let names = |app: &mut VectorcraftApp, kind| styles(app, kind).into_iter().skip(1).map(|s| s.0).collect::>(); + for kind in [Kind::Char, Kind::Para] { + let (mut app, _) = several_app(); + let ctx = egui::Context::default(); + let items = |app: &mut VectorcraftApp, ui: &mut egui::Ui| menu(app, ui, kind); + let one = format!(" Load {} Styles…", kind.what()); + let doc = app.session.doc().unwrap().doc.clone(); + click_text_held(&ctx, &mut app, &one, egui::Modifiers::NONE, items); + assert_eq!(app.session.doc().unwrap().doc, doc, "{} Styles: off without an open dialog", kind.what()); + let picked = path.to_string_lossy().to_string(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + let undo = app.session.doc().unwrap().history.undo.len(); + click_text_held(&ctx, &mut app, &one, egui::Modifiers::NONE, items); + let (added, other) = if kind == Kind::Char { ("Lead", Kind::Para) } else { ("Centered", Kind::Char) }; + assert_eq!(names(&mut app, kind), ["A", "B", "C", "D", added], "{} Styles", kind.what()); + assert_eq!(names(&mut app, other), ["A", "B", "C", "D"], "{} Styles: the other kind", kind.what()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + click_text_held(&ctx, &mut app, " Load All Styles…", egui::Modifiers::NONE, items); + assert_eq!(names(&mut app, Kind::Char), ["A", "B", "C", "D", "Lead"], "{} Styles: Load All Styles", kind.what()); + assert_eq!(names(&mut app, Kind::Para), ["A", "B", "C", "D", "Centered"], "{} Styles: Load All Styles", kind.what()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); + } + std::fs::remove_dir_all(&dir).ok(); +} + /// Feed real text/shortcut events through the canvas Type routing. No focus reset or typing /// settlement here: the capture dispatch itself must close the live typing interaction. fn capture_input(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs new file mode 100644 index 000000000..25dfaa684 --- /dev/null +++ b/crates/ui-egui/src/panels/three_d.rs @@ -0,0 +1,272 @@ +//! Window › 3D and Materials: edits the selection's 3D effect in place. The object types (Plane, +//! Extrude, Revolve, Inflate) apply a 3D effect to a selection without one (`effect.apply`) or +//! change the applied one (`effect.setParams` with `threeDType`). Below them are the 3D effect +//! dialogs' own Object, Rotation and Lighting controls; each change runs `effect.setParams` as one +//! undo step, and a drag previews and commits once, when the pointer is released. + +use egui::Ui; +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{Effect, Node}; +use vectorcraft_effects::{THREE_D_TYPES, is_three_d}; + +use super::{live_run, pstate, set_pstate}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::widgets::{self, Live, dim_label}; + +/// The panel's id (`window.panel`). +pub const ID: &str = "threeD"; + +/// The name of the undo step a change makes, as the effect dialogs' commits name it. +const LABEL: &str = "Effect Options"; + +/// The selection's 3D effect: the appearance item whose effects hold it (`None`: the object's +/// own effects), its index there, and its id and parameters. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Target { + pub item: Option, + pub index: usize, + pub effect: String, + pub params: Value, +} + +/// The 3D effect of the first selected object: its own effects first, then its fills' and +/// strokes'. `None` without a selection or without a 3D effect. +pub(crate) fn target(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let n = st.selection.objects.first().and_then(|id| st.doc.node(*id))?; + find(n) +} + +fn find(n: &Node) -> Option { + let in_list = |fx: &[Effect], item: Option| { + fx.iter().position(|e| is_three_d(&e.id)).map(|index| Target { item, index, effect: fx[index].id.clone(), params: fx[index].params.clone() }) + }; + in_list(&n.appearance.effects, None).or_else(|| n.appearance.items.iter().enumerate().find_map(|(k, item)| in_list(item.effects(), Some(k)))) +} + +/// The panel's object type of effect `id` (`plane`, `extrude`, `revolve` or `inflate`). +pub(crate) fn type_of(id: &str) -> Option<&'static str> { + THREE_D_TYPES.iter().find(|(_, effect)| *effect == id).map(|(kind, _)| *kind) +} + +/// Picks object type `kind` for each selected object: changes its 3D effect into that type, or +/// applies that type's effect to it when it has none, all as one undo step. An object whose 3D +/// effect already has that type is left as it is (`effect.setParams` records nothing for it). +pub(crate) fn set_type(app: &mut VectorcraftApp, kind: &str) -> Result<(), String> { + let Some(&(_, effect)) = THREE_D_TYPES.iter().find(|(k, _)| *k == kind) else { + let key = "threeDType"; + return Err(format!("`{key}` must be one of {}", THREE_D_TYPES.map(|(k, _)| k).join(", "))); + }; + let steps: Vec<(&str, Value)> = app.session.active().map_or_else(Vec::new, |st| { + st.selection + .objects + .iter() + .filter_map(|id| st.doc.node(*id).map(|n| (id.0, find(n)))) + .map(|(id, t)| match t { + Some(t) => ("effect.setParams", json!({"ids": [id], "index": t.index, "item": t.item, "threeDType": kind})), + None => ("effect.apply", json!({"ids": [id], "effect": effect, "item": null})), + }) + .collect() + }); + match steps.as_slice() { + // Without a selection, `effect.apply` says why nothing changed. + [] => app.run("effect.apply", json!({"effect": effect, "item": null})).map(|_| ()), + [(id, p)] => app.run(id, p.clone()).map(|_| ()), + _ => { + app.run("edit.undoGroup.begin", json!({"label": LABEL}))?; + for (id, p) in &steps { + if let Err(e) = app.run(id, p.clone()) { + app.run("edit.undoGroup.end", json!({"cancel": true})).ok(); + return Err(e); + } + } + app.run("edit.undoGroup.end", json!({})).map(|_| ()) + } + } +} + +/// Commits the drag in progress, when the panel has one. +fn finish(app: &mut VectorcraftApp, ctx: &egui::Context) { + if !pstate::>(ctx, PENDING).is_empty() { + app.session.commit_interaction().ok(); + app.sync_views(); + } + set_pstate(ctx, PENDING, Map::::new()); + set_pstate(ctx, PLAN, None::); +} + +/// What a change goes to, taken when it starts (before any preview): the selected objects' ids, +/// and each selected object with a 3D effect, the effect, and whether its list of lights matches +/// the first one's (a light list edited in the panel replaces only those). +#[derive(Clone, Debug)] +struct Plan { + selection: Vec, + targets: Vec<(u64, Target, bool)>, +} + +fn plan(app: &VectorcraftApp) -> Plan { + let Some(st) = app.session.active() else { return Plan { selection: vec![], targets: vec![] } }; + let selection: Vec = st.selection.objects.iter().map(|id| id.0).collect(); + let found: Vec<(u64, Target)> = st.selection.objects.iter().filter_map(|id| Some((id.0, find(st.doc.node(*id)?)?))).collect(); + let lights = |t: &Target| vectorcraft_effects::merged_params(&t.effect, &t.params).get("lights").cloned(); + let first = found.first().map(|(_, t)| lights(t)); + let targets = found + .into_iter() + .map(|(id, t)| { + let same = first.as_ref() == Some(&lights(&t)); + (id, t, same) + }) + .collect(); + Plan { selection, targets } +} + +/// Sends the settings `changed` to the 3D effect of each selected object (only the settings its +/// effect takes; objects without a 3D effect are left as they are): previews them while the +/// pointer is down (`dragging`) and commits them as one undo step when it is released. Settings +/// changed earlier in the same drag are sent again with them, to the objects the drag started +/// with, since each preview starts again from the document as it was before the drag. A drag +/// whose selection changes is committed first, and the change starts a new one. +pub(crate) fn send(app: &mut VectorcraftApp, ctx: &egui::Context, changed: Map, dragging: bool) { + if changed.is_empty() { + // A drag that ended without a change in its last frame is committed now. + if !dragging { + finish(app, ctx); + } + return; + } + let selection: Vec = app.session.active().map(|st| st.selection.objects.iter().map(|id| id.0).collect()).unwrap_or_default(); + let mut held: Option = pstate(ctx, PLAN); + if held.as_ref().is_some_and(|p| p.selection != selection) { + finish(app, ctx); + held = None; + } + let plan = held.unwrap_or_else(|| plan(app)); + let mut pending: Map = pstate(ctx, PENDING); + pending.extend(changed); + let steps: Vec = plan + .targets + .iter() + .filter_map(|(id, t, same_lights)| { + let takes = vectorcraft_effects::default_params(&t.effect).unwrap_or(Value::Null); + let params: Map = pending + .iter() + .filter(|(k, _)| takes.get(k.as_str()).is_some() && (k.as_str() != "lights" || *same_lights)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + (!params.is_empty()) + .then(|| json!({"command": "effect.setParams", "params": {"ids": [id], "index": t.index, "item": t.item, "params": params}})) + }) + .collect(); + let phase = if dragging { Live::Dragging } else { Live::Released }; + match steps.as_slice() { + [] => {} + [step] => live_run(app, LABEL, "effect.setParams", step["params"].clone(), phase), + _ => live_run(app, LABEL, "command.batch", json!({"label": LABEL, "commands": steps}), phase), + } + if dragging { + set_pstate(ctx, PENDING, pending); + set_pstate(ctx, PLAN, Some(plan)); + } else { + set_pstate(ctx, PENDING, Map::::new()); + set_pstate(ctx, PLAN, None::); + } +} + +/// Panel state key of the [`Plan`] of the drag in progress. +const PLAN: &str = "three-d-plan"; +/// Panel state key of the pass the panel was last drawn in. +const DRAWN: &str = "three-d-drawn"; + +/// Commits the panel's drag in progress when the panel was not drawn in this pass (it was closed +/// or hidden during the drag), so the drag is one undo step and no preview stays open into a later +/// pass. The app runs this at the end of every pass, after the panels. +pub(crate) fn end_stale_drag(app: &mut VectorcraftApp, ctx: &egui::Context) { + let drawn: Option = pstate(ctx, DRAWN); + if drawn.is_some_and(|pass| pass != root_pass(ctx)) { + finish(app, ctx); + set_pstate(ctx, DRAWN, None::); + } +} + +/// The main window's pass in progress. A floating window draws inside it and counts its own +/// passes, so the panel's mark uses this one wherever the panel draws. +fn root_pass(ctx: &egui::Context) -> u64 { + ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT) +} + +/// Panel state key of the settings changed during the drag in progress. +const PENDING: &str = "three-d-pending"; +/// Panel state key of the controls' own state (the open tab, the selected light). +const UI_STATE: &str = "three-d-ui"; + +/// The 3D and Materials panel. +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + set_pstate(&ctx, DRAWN, Some(root_pass(&ctx))); + let target = target(app); + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + let current = target.as_ref().and_then(|t| type_of(&t.effect)); + let labels = [tl!("Plane"), tl!("Extrude"), tl!("Revolve"), tl!("Inflate")]; + let at = current.and_then(|kind| THREE_D_TYPES.iter().position(|(k, _)| *k == kind)).unwrap_or(usize::MAX); + let picked = ui.add_enabled_ui(selected, |ui| widgets::tab_bar(ui, &labels, at)).inner; + if let Some((kind, _)) = picked.and_then(|i| THREE_D_TYPES.get(i)) + && let Err(e) = set_type(app, kind) + { + app.ui.status = e; + } + ui.add_space(8.0); + let Some(t) = target else { + // The selection changed during a drag: the drag ends with what it previewed. + finish(app, &ctx); + dim_label(ui, tl!("Select objects, then pick an object type to give them a 3D effect.")); + return; + }; + // The applied effect's settings in the dialog's form, with the controls' own state. + let mut fields = vectorcraft_effects::merged_params(&t.effect, &t.params).as_object().cloned().unwrap_or_default(); + mark_mixed(app, &mut fields); + fields.extend(pstate::>(&ctx, UI_STATE)); + fields.insert("__effect".into(), json!(t.effect)); + fields.insert("__panel".into(), json!(true)); + let mut d = Dialog { kind: ID.into(), fields }; + let before = crate::dialogs::form::params(&d); + crate::dialogs::revolve::controls(app, ui, &mut d); + let state: Map = d + .fields + .iter() + .filter(|(k, _)| k.starts_with("__") && !matches!(k.as_str(), "__effect" | "__panel")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + set_pstate(&ctx, UI_STATE, state); + let after = crate::dialogs::form::params(&d); + let changed = changed_settings(&before, &after); + let dragging = ctx.input(|i| i.pointer.any_down()); + send(app, &ctx, changed, dragging); +} + +/// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect +/// of another selected object has with another value. The controls then show it at its default +/// (a checkbox shows a dash) with its reset dot filled, and its reset dot, a menu choice, a checkbox click, or a number other +/// than the default shown is a change sent to every effect. The light list (`lights`, where null +/// is the single light) is left as it is. +pub(crate) fn mark_mixed(app: &VectorcraftApp, fields: &mut Map) { + let Some(st) = app.session.active() else { return }; + for other in st.selection.objects.iter().skip(1).filter_map(|id| find(st.doc.node(*id)?)) { + let other = vectorcraft_effects::merged_params(&other.effect, &other.params); + for (key, value) in fields.iter_mut().filter(|(k, _)| k.as_str() != "lights") { + if other.get(key.as_str()).is_some_and(|v| v != value) { + *value = Value::Null; + } + } + } +} + +/// The settings whose values differ between `before` and `after`, with their values in `after`. +pub(crate) fn changed_settings(before: &Value, after: &Value) -> Map { + let Some(after) = after.as_object() else { return Map::new() }; + after.iter().filter(|(k, v)| before.get(k.as_str()) != Some(*v)).map(|(k, v)| (k.clone(), v.clone())).collect() +} + +#[cfg(test)] +#[path = "tests_three_d.rs"] +mod tests; diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs index e066dffe8..a945f9ec1 100644 --- a/crates/ui-egui/src/panels/transform/tests.rs +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -426,6 +426,42 @@ fn use_registration_point_for_symbol_follows_a_transformed_instance() { } } +/// The reference point locator is one tab stop: from the widget before it, two presses of Tab reach +/// X, the first stopping on the whole locator; → on the locator moves the shared reference point to +/// the middle of the right side. +#[test] +fn the_reference_point_locator_is_one_tab_stop_and_arrows_move_it() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "70 pt").center(), show); + let x = ctx.memory(|m| m.focused()); + assert!(x.is_some(), "the click put the focus in X"); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let mut press = |key| { + frame_events(&ctx, &mut app, vec![key], show); + frame_events(&ctx, &mut app, vec![], show); + ctx.memory(|m| m.focused()) + }; + let locator = press(key(egui::Key::Tab, egui::Modifiers::SHIFT)); + let rect = |id: Option| id.and_then(|id| ctx.read_response(id)).map(|r| r.rect); + let grid = rect(locator).unwrap_or(egui::Rect::NOTHING); + assert_eq!(grid.size(), egui::Vec2::splat(23.0), "Shift+Tab from X reaches the whole locator, not one of its squares"); + let before = press(key(egui::Key::Tab, egui::Modifiers::SHIFT)); + assert!(before.is_some() && rect(before).is_some_and(|r| !grid.contains_rect(r)), "a second Shift+Tab leaves the locator"); + assert_eq!(press(key(egui::Key::Tab, egui::Modifiers::NONE)), locator, "Tab from the widget before the locator reaches it"); + assert_eq!(press(key(egui::Key::Tab, egui::Modifiers::NONE)), x, "a second Tab reaches X"); + press(key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::unique("refpt"))), None, "→ in X leaves the reference point alone"); + press(key(egui::Key::Tab, egui::Modifiers::SHIFT)); + press(key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::unique("refpt"))), Some(5), "→ on the locator chose the middle of the right side"); +} + #[test] fn artboard_fields_keep_the_reference_point() { let r = Rect::new(0.0, 0.0, 300.0, 200.0); diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 81253e83c..92314bfba 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -76,6 +76,7 @@ const DOCUMENT_FREE: &[&str] = &[ "ui.libraryPanel.locate", "shortcuts.import", "shortcuts.export", + "shortcuts.exportText", "ui.shortcutImportDialog", crate::panels::actions::SAVE_SET, crate::panels::actions::LOAD_SET, diff --git a/crates/ui-egui/src/plugin_runs.rs b/crates/ui-egui/src/plugin_runs.rs index 719eadc18..a1711ee8f 100644 --- a/crates/ui-egui/src/plugin_runs.rs +++ b/crates/ui-egui/src/plugin_runs.rs @@ -9,12 +9,18 @@ //! ([`FilterRun::hand_in`]), so the edit is one undo step like any run, unless the document or the //! plug-in changed meanwhile: then nothing changes and the status bar says to run it again. On the //! web (no threads) it runs at once ([`available`]). +//! +//! A filter of plug-in API level 2 may report how far it has got (`vc_progress`): the progress +//! window then shows a bar with the share done. Its Cancel also makes the filter's `vc_cancelled` +//! return 1, so a filter that checks it stops at once; one that doesn't stops at the next slice of +//! its budget. +use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError, channel}; use serde_json::{Value, json}; -use vectorcraft_doc::cancel::{self, CancelToken}; use vectorcraft_engine::EngineError; +use vectorcraft_engine::cmd::fileio::progress::{self, Progress}; use vectorcraft_engine::cmd::plugin::FilterRun; use crate::VectorcraftApp; @@ -34,8 +40,8 @@ pub struct Running { who: String, /// `plugin.run`'s params that apply its output: the objects it was given as `ids`. params: Value, - /// Its Cancel. - token: CancelToken, + /// How far it has got, as it reports it, and its Cancel. + pub progress: Arc, /// Continue in Background: the progress window no longer shows it. pub hidden: bool, done: Receiver, @@ -44,7 +50,7 @@ pub struct Running { impl Running { /// Does the progress window show it: neither hidden (Continue in Background) nor cancelled? fn shown(&self) -> bool { - !self.hidden && !self.token.is_cancelled() + !self.hidden && !self.progress.cancelled() } } @@ -72,12 +78,12 @@ pub fn start(app: &mut VectorcraftApp, p: Value) -> Result { let mut params = p; params["ids"] = json!(run.inputs().iter().map(|i| i.0).collect::>()); let (tx, done) = channel(); - let token = CancelToken::new(); - let stop = token.clone(); + let shown = Progress::new(); + let reporting = Arc::clone(&shown); std::thread::Builder::new() .name("vectorcraft-plugin".into()) .spawn(move || { - let output = vectorcraft_engine::guard::catch_panic(|| cancel::scope(&stop, None, || run.run())) + let output = vectorcraft_engine::guard::catch_panic(|| progress::scope(&reporting, || run.run())) .map_err(|msg| format!("internal error: {msg} (please report this bug)")) .and_then(|r| r.map_err(|e| e.to_string())); // The app may have quit meanwhile: nothing to hand it to. @@ -87,7 +93,7 @@ pub fn start(app: &mut VectorcraftApp, p: Value) -> Result { let label = format!("Running {who}…"); let runs = &mut app.plugin_runs; runs.next_id += 1; - runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, token, hidden: false, done }); + runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, progress: shown, hidden: false, done }); app.status(label.clone()); Ok(json!({ "background": true, "status": label })) } @@ -121,7 +127,7 @@ pub fn poll(app: &mut VectorcraftApp) { /// they were; else why it has none, in the status bar. fn finish(app: &mut VectorcraftApp, r: Running, done: Option) { // Cancel changes nothing, even when the run ended just before it. - if r.token.is_cancelled() { + if r.progress.cancelled() { app.status(EngineError::Cancelled.to_string()); return; } @@ -146,12 +152,12 @@ fn finish(app: &mut VectorcraftApp, r: Running, done: Option) { } } -/// The progress window's Cancel (also Esc): every heavy filter running stops at the next slice of -/// its budget and changes nothing → `{cancelled: n}`. +/// The progress window's Cancel (also Esc): every heavy filter running stops (at once when it checks +/// `vc_cancelled`, else at the next slice of its budget) and changes nothing → `{cancelled: n}`. pub fn cancel_all(app: &mut VectorcraftApp) -> Value { let mut n = 0; - for r in app.plugin_runs.running.iter().filter(|r| !r.token.is_cancelled()) { - r.token.cancel(); + for r in app.plugin_runs.running.iter().filter(|r| !r.progress.cancelled()) { + r.progress.cancel(); n += 1; } json!({ "cancelled": n }) @@ -170,7 +176,7 @@ pub fn hide_all(app: &mut VectorcraftApp) -> Value { /// Is a heavy filter running that hasn't been cancelled? pub fn any_running(app: &VectorcraftApp) -> bool { - app.plugin_runs.running.iter().any(|r| !r.token.is_cancelled()) + app.plugin_runs.running.iter().any(|r| !r.progress.cancelled()) } /// Is a heavy filter running that the progress window shows? @@ -195,7 +201,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } let heading = crate::i18n::msg(&first.label); - let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| export_progress::busy(ui, tl!("The plug-in is computing…"))); + let reported = first.progress.fraction(); + let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| match reported { + Some(share) => export_progress::share(ui, tl!("The plug-in is computing…"), share as f32), + None => export_progress::busy(ui, tl!("The plug-in is computing…")), + }); let id = match pressed { Some(Pressed::Cancel) => "plugin.cancelRuns", Some(Pressed::Hide) => "plugin.hideRuns", diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 20b9b2e82..c3532da83 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -32,9 +32,158 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides)); fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid)); fields.insert("__taskBar".into(), json!(app.ui.task_bar)); + fields.insert("__search".into(), json!("")); + fields.insert("__focusSearch".into(), json!(true)); app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields }); } +/// A setting the dialog shows, by its key, category, section ("" for none) and label, in English. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Setting { + pub key: &'static str, + pub category: &'static str, + pub section: &'static str, + pub label: &'static str, +} + +/// Every setting the dialog shows on this platform: the UI-only fields, then the engine's +/// preferences, each category's in the order the dialog lists them. +fn settings() -> impl Iterator { + let ui_fields = UI_FIELDS.iter().map(|&(key, category, label)| Setting { key, category, section: "", label }); + let shown = |key: &str| match key { + "systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))), + "linuxDisplayServer" => cfg!(target_os = "linux"), + _ => true, + }; + let prefs = + PREF_SPECS.iter().filter(move |s| shown(s.key)).map(|s| Setting { key: s.key, category: s.category, section: s.section, label: s.label }); + ui_fields.chain(prefs) +} + +/// The settings whose label, section or category contains `query`, ignoring case, in the language +/// the dialog is drawn in or in English, grouped by category in the dialog's order. An empty +/// `query` finds none. +pub(crate) fn search(query: &str) -> Vec { + let q = query.trim().to_lowercase(); + if q.is_empty() { + return vec![]; + } + let hit = |s: &str| !s.is_empty() && (tl!(s).to_lowercase().contains(&q) || s.to_lowercase().contains(&q)); + let mut found: Vec = settings().filter(|s| hit(s.label) || hit(s.section) || hit(s.category)).collect(); + found.sort_by_key(|s| PREF_CATEGORIES.iter().position(|c| *c == s.category).unwrap_or(usize::MAX)); + found +} + +/// Goes to the setting `s` a search found: its category is shown, the list scrolls to it and its +/// row is highlighted. The search field is cleared. +fn go_to(d: &mut Dialog, s: &Setting) { + d.fields.insert("__category".into(), json!(s.category)); + d.fields.insert("__found".into(), json!(s.key)); + d.fields.insert("__scrollTo".into(), json!(true)); + d.fields.insert("__search".into(), json!("")); +} + +/// The search field's memory key ([`widgets::search_field`]). +fn search_id() -> egui::Id { + egui::Id::unique("prefs-search") +} + +/// The search field's text edit, which takes the keyboard focus. +pub(crate) fn search_edit_id() -> egui::Id { + search_id().with("edit") +} + +/// Whether the search field had the keyboard focus when the dialog was last drawn. +fn search_focused_id() -> egui::Id { + egui::Id::unique("prefs-search-focused") +} + +/// Escape while the Preferences dialog's search field has text and the keyboard focus: the field is +/// cleared and keeps the focus, and the Escape goes no further. → Whether it did; otherwise Escape +/// does what it does in any dialog. +pub(crate) fn clear_search_on_escape(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { + let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == "preferences") else { return false }; + // egui takes the focus from the field when the frame with Escape begins: the field's focus is + // the one recorded when the dialog was last drawn. + if d.str("__search").is_empty() || !ctx.data(|m| m.get_temp::(search_focused_id()).unwrap_or(false)) { + return false; + } + d.fields.insert("__search".into(), json!("")); + d.fields.insert("__focusSearch".into(), json!(true)); + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. }))); + true +} + +/// The search field above the category list. It takes the keyboard focus when the dialog opens and +/// on Cmd/Ctrl+F. → Its text. +fn search_box(ui: &mut egui::Ui, d: &mut Dialog) -> String { + let refocus = ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::F)); + if refocus { + d.fields.insert("__focusSearch".into(), json!(true)); + } + ui.data_mut(|m| m.insert_temp(search_id(), d.str("__search"))); + let query = widgets::search_field(ui, search_id(), tl!("Search Preferences")); + if d.bool("__focusSearch") { + // Not on the window's measuring frame, nor in the frame of the click that opened the + // dialog: egui takes the focus from a field such a click missed. + if ui.is_sizing_pass() || ui.input(|i| i.pointer.any_click()) { + ui.ctx().request_repaint(); + } else { + widgets::focus_field(ui, search_edit_id(), &query); + d.fields.remove("__focusSearch"); + } + } + let focused = ui.memory(|m| m.has_focus(search_edit_id())); + ui.data_mut(|m| m.insert_temp(search_focused_id(), focused)); + d.fields.insert("__search".into(), json!(query)); + query +} + +/// The settings `query` finds, in place of the category list: choosing one goes to it. +fn search_results(ui: &mut egui::Ui, d: &mut Dialog, query: &str) { + let t = Tokens::get(ui.ctx()); + let found = search(query); + if found.is_empty() { + ui.label(egui::RichText::new(tl!("No settings found")).color(t.text_dim).size(12.0)); + return; + } + egui::ScrollArea::vertical().id_salt("prefs-search-results").max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + for s in &found { + let text = egui::RichText::new(tl!(s.label)).size(12.5).color(t.text); + let place = if s.section.is_empty() { tl!(s.category).to_string() } else { format!("{} › {}", tl!(s.category), tl!(s.section)) }; + let b = egui::Button::selectable(false, text).min_size(egui::vec2(180.0, 22.0)); + if ui.add(b).on_hover_text(&place).clicked() { + go_to(d, s); + } + ui.label(egui::RichText::new(place).color(t.text_dim).size(11.0)); + ui.add_space(3.0); + } + }); +} + +/// Before a setting's row: where to paint the highlight behind it, when it is the setting a search +/// went to ([`go_to`]). +fn found_mark(ui: &mut egui::Ui, d: &Dialog, key: &str) -> Option<(egui::layers::ShapeIdx, f32)> { + (d.str("__found") == key).then(|| (ui.painter().add(egui::Shape::Noop), ui.cursor().top())) +} + +/// After the row [`found_mark`] marked: highlights it, and scrolls the list to it once. +fn found_paint(ui: &mut egui::Ui, d: &mut Dialog, mark: Option<(egui::layers::ShapeIdx, f32)>) { + let Some((idx, top)) = mark else { return }; + let bottom = ui.min_rect().bottom(); + if bottom <= top { + return; + } + let rect = egui::Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=bottom).expand2(egui::vec2(0.0, 2.0)); + let t = Tokens::get(ui.ctx()); + ui.painter().set(idx, egui::epaint::RectShape::filled(rect, 3.0, t.accent.gamma_multiply(0.22))); + if d.bool("__scrollTo") && !ui.is_sizing_pass() { + ui.scroll_to_rect(rect, Some(egui::Align::Center)); + d.fields.remove("__scrollTo"); + } +} + /// OK: validate and apply every preference in the dialog (nothing changes on error). pub fn confirm(app: &mut VectorcraftApp) -> Result { let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; @@ -189,20 +338,32 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { crate::dialogs::modal::heading(ui, tl!("Preferences")); ui.add_space(12.0); ui.horizontal_top(|ui| { - // Category list. - egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| { - ui.set_width(196.0); - ui.set_min_height(430.0); - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 1.0; - for c in PREF_CATEGORIES { - let sel = *c == cat; - let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text }); - let b = egui::Button::selectable(sel, text).min_size(egui::vec2(184.0, 24.0)); - if ui.add(b).clicked() { - d.fields.insert("__category".into(), json!(c)); + ui.vertical(|ui| { + ui.set_width(208.0); + // Search, above the category list; while it has text, the settings it finds show + // in place of the list. + let query = search_box(ui, &mut d); + ui.add_space(6.0); + // Category list. + let list = egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)); + list.show(ui, |ui| { + ui.set_width(196.0); + ui.set_min_height(396.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + if !query.trim().is_empty() { + search_results(ui, &mut d, &query); + return; } - } + for c in PREF_CATEGORIES { + let sel = *c == cat; + let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text }); + let b = egui::Button::selectable(sel, text).min_size(egui::vec2(184.0, 24.0)); + if ui.add(b).clicked() { + d.fields.insert("__category".into(), json!(c)); + } + } + }); }); }); ui.add_space(14.0); @@ -248,6 +409,12 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { d.fields.insert(k, v); } } + // The setting a search went to stays highlighted until another category is shown. + let found = d.str("__found"); + if !found.is_empty() && settings().find(|s| s.key == found).is_none_or(|s| s.category != d.str("__category")) { + d.fields.remove("__found"); + d.fields.remove("__scrollTo"); + } app.ui.dialog = Some(d); if cancel { app.ui.dialog = None; @@ -263,11 +430,15 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, let specs: Vec<_> = PREF_SPECS.iter().filter(|s| s.category == cat).collect(); for (key, c, label) in UI_FIELDS { if *c == cat && cat != "Smart Guides" { + let mark = found_mark(ui, d, key); bool_row(ui, d, key, label); + found_paint(ui, d, mark); } } if cat == "Smart Guides" { + let mark = found_mark(ui, d, "__smartGuides"); bool_row(ui, d, "__smartGuides", UI_FIELDS[0].2); + found_paint(ui, d, mark); ui.add_space(4.0); } for sp in specs { @@ -283,6 +454,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, } first = false; let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null); + let mark = found_mark(ui, d, sp.key); match sp.kind { // Units › Numbers Without Units Are Points only tells points from picas: dimmed // unless a unit is Picas. @@ -409,6 +581,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, }); } } + found_paint(ui, d, mark); } } diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 8d0285254..a43ec6677 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -324,6 +324,157 @@ fn the_category_shown_is_highlighted() { assert_eq!(filled, vec![3], "{rows:?}"); } +/// One frame of the Preferences dialog with `events`, keys first as the app takes them. +fn search_frame(a: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::shortcuts::handle(a, ui.ctx()); + show(a, ui.ctx()); + }); + out.textures_delta.clear(); + out +} + +/// A key pressed and let go in one frame. +fn key_press(key: egui::Key, modifiers: egui::Modifiers) -> Vec { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec() +} + +fn search_text(a: &VectorcraftApp) -> String { + a.ui.dialog.as_ref().map(|d| d.str("__search")).unwrap_or_default() +} + +/// Preferences' search finds a setting by its label, ignoring case; an empty search finds nothing. +#[test] +fn a_search_finds_a_setting_by_its_label() { + let keys = |q: &str| search(q).iter().map(|s| s.key).collect::>(); + assert!(keys("tool tips").contains(&"showToolTips"), "{:?}", keys("tool tips")); + assert!(keys("SHOW TOOL TIPS").contains(&"showToolTips")); + assert!(keys("snap to grid").contains(&"__snapToGrid")); + let found = search("gridline every"); + assert_eq!(found.iter().map(|s| (s.key, s.category)).collect::>(), [("gridlineEvery", "Guides & Grid")]); + assert!(search("").is_empty() && search(" ").is_empty()); + assert!(search("no such setting at all").is_empty()); +} + +/// A translated label matches while the dialog is drawn in that language. +#[test] +fn a_translated_label_matches_in_the_interface_language() { + let de = crate::i18n::Lang::from_code("de").expect("de registered"); + let label = crate::i18n::tr(de, "Show Tool Tips"); + assert_ne!(label, "Show Tool Tips", "German has a row for the label"); + crate::i18n::set_current(de); + let found: Vec<_> = search(&label.to_uppercase()).iter().map(|s| s.key).collect(); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert!(found.contains(&"showToolTips"), "{label}: {found:?}"); +} + +/// An empty search field shows the category list; a search that finds nothing says so. +#[test] +fn an_empty_search_lists_the_categories_and_no_match_says_so() { + let mut a = app(); + open(&mut a, Some("General")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(PREF_CATEGORIES.iter().all(|c| text.contains(c)), "{text}"); + assert!(!text.contains("No settings found"), "{text}"); + a.ui.dialog.as_mut().unwrap().fields.insert("__search".into(), json!("no such setting at all")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains("No settings found"), "{text}"); + assert!(!text.contains("Plug-ins & Scratch Disks"), "the list is hidden: {text}"); + a.ui.dialog.as_mut().unwrap().fields.insert("__search".into(), json!(" ")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(PREF_CATEGORIES.iter().all(|c| text.contains(c)), "{text}"); +} + +/// Choosing a setting the search found shows its category, clears the search and highlights the +/// setting's row. +#[test] +fn choosing_a_found_setting_goes_to_its_category() { + let mut a = app(); + open(&mut a, Some("General")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + a.ui.dialog.as_mut().unwrap().fields.insert("__search".into(), json!("gridline")); + search_frame(&mut a, &ctx, vec![]); + let out = search_frame(&mut a, &ctx, vec![]); + let mut at = None; + fn find(s: &egui::Shape, label: &str, at: &mut Option) { + match s { + egui::Shape::Text(t) if t.galley.text() == label => *at = Some(t.pos + t.galley.rect.center().to_vec2()), + egui::Shape::Vec(v) => v.iter().for_each(|s| find(s, label, at)), + _ => {} + } + } + out.shapes.iter().for_each(|c| find(&c.shape, "Gridline every", &mut at)); + let at = at.expect("the found setting is listed"); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + search_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(at), button(true)]); + search_frame(&mut a, &ctx, vec![button(false)]); + let d = a.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__category").as_str(), d.str("__found").as_str(), d.str("__search").as_str()), ("Guides & Grid", "gridlineEvery", "")); + search_frame(&mut a, &ctx, vec![]); + let out = search_frame(&mut a, &ctx, vec![]); + fn rects(s: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) { + match s { + egui::Shape::Rect(r) => out.push((r.rect, r.fill)), + egui::Shape::Vec(v) => v.iter().for_each(|s| rects(s, out)), + _ => {} + } + } + let mut shown = vec![]; + out.shapes.iter().for_each(|c| rects(&c.shape, &mut shown)); + let mut label = None; + out.shapes.iter().for_each(|c| find(&c.shape, "Gridline every:", &mut label)); + let label = label.expect("the setting's row is shown"); + // A tinted band across the fields column, behind the setting's label. + let band = shown.iter().any(|(r, fill)| r.width() >= 500.0 && r.height() < 40.0 && fill.a() > 0 && r.contains(label)); + assert!(band, "the setting's row is highlighted: {shown:?}"); + assert!(!a.ui.dialog.as_ref().unwrap().bool("__scrollTo"), "the list scrolled to it"); + // Another category: the highlight goes. + a.ui.dialog.as_mut().unwrap().fields.insert("__category".into(), json!("Units")); + search_frame(&mut a, &ctx, vec![]); + assert_eq!(a.ui.dialog.as_ref().unwrap().str("__found"), ""); +} + +/// The search field has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back. +#[test] +fn the_search_field_takes_the_focus_on_open_and_on_cmd_f() { + let mut a = app(); + open(&mut a, None); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let focused = |ctx: &egui::Context| ctx.memory(|m| m.has_focus(search_edit_id())); + search_frame(&mut a, &ctx, vec![]); + search_frame(&mut a, &ctx, vec![]); + assert!(focused(&ctx), "focused on open"); + ctx.memory_mut(|m| m.surrender_focus(search_edit_id())); + search_frame(&mut a, &ctx, vec![]); + assert!(!focused(&ctx)); + search_frame(&mut a, &ctx, key_press(egui::Key::F, egui::Modifiers::COMMAND)); + assert!(focused(&ctx), "focused on Cmd/Ctrl+F"); + assert!(a.ui.dialog.is_some()); +} + +/// Escape in the search field with text clears it and keeps the dialog open with the field +/// focused; Escape in the empty field closes the dialog. +#[test] +fn escape_clears_the_search_then_closes_the_dialog() { + let mut a = app(); + open(&mut a, None); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + search_frame(&mut a, &ctx, vec![]); + search_frame(&mut a, &ctx, vec![]); + search_frame(&mut a, &ctx, vec![egui::Event::Text("grid".into())]); + assert_eq!(search_text(&a), "grid"); + search_frame(&mut a, &ctx, key_press(egui::Key::Escape, egui::Modifiers::NONE)); + assert!(a.ui.dialog.is_some(), "the first Escape leaves the dialog open"); + assert_eq!(search_text(&a), ""); + search_frame(&mut a, &ctx, vec![]); + assert!(ctx.memory(|m| m.has_focus(search_edit_id())), "the field keeps the focus"); + search_frame(&mut a, &ctx, key_press(egui::Key::Escape, egui::Modifiers::NONE)); + assert!(a.ui.dialog.is_none(), "Escape in the empty field closes the dialog"); +} + /// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it /// changes, and its checkbox is dimmed unless a unit is Picas. #[test] diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 3a7f5d89a..bf764e0e4 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -24,6 +24,12 @@ pub struct Job { /// The automation roots in force where the job was made: the render reads linked files under /// them, as it would on that thread. pub scope: ScopeHandle, + /// The render may copy pixels from the worker's last frame ([`crate::canvas::render_job`]). + /// Off: it renders the whole frame. + pub may_copy: bool, + /// The worker may keep the frame for its next frame to copy from + /// ([`crate::canvas::render_job`]). Off: the worker holds no frame after this one. + pub may_keep: bool, } pub struct Done { @@ -35,6 +41,9 @@ pub struct Done { pub ms: f64, /// [`vectorcraft_render::FrameStats::moved`] of the frame. pub moved: usize, + /// The frame holds pixels copied from the worker's last frame ([`crate::canvas::render_job`]), + /// and `ms` is the time of the copy and of the parts rendered. + pub reused: bool, } pub struct Worker { @@ -55,15 +64,17 @@ impl Worker { .name("vectorcraft-render".into()) .spawn(move || { let mut r = Renderer::new(); + let mut kept = None; while let Ok(mut job) = jobs.recv() { // Skip stale jobs: only the newest matters. while let Ok(newer) = jobs.try_recv() { job = newer; } let t0 = crate::now_ms(); - let img = job.scope.clone().run(|| r.render(&job.doc, job.w, job.h, job.view, &job.opts)); - if done_tx.send(Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: crate::now_ms() - t0, moved: r.stats.moved }).is_err() - { + let frame = job.scope.clone().run(|| crate::canvas::render_job(&mut r, &mut kept, &job)); + let ms = crate::now_ms() - t0; + let done = Done { key: job.key, doc: job.doc, opts: job.opts, img: frame.img, ms, moved: frame.moved, reused: frame.reused }; + if done_tx.send(done).is_err() { break; } ctx.request_repaint(); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 81b152a79..47374c7e6 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -169,6 +169,11 @@ pub struct Services { /// bar's open menu) for floating panels in OS windows of their own: eframe runs the host's /// `raw_input_hook` for the main window only. pub native_input_hook: Option, + /// The connected monitors, kept current by the desktop host: floating panels in OS windows of + /// their own store the monitor they are on and reopen on it. The rects are in the points egui + /// 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, } /// Rewrites one window's raw input before egui reads it ([`Services::native_input_hook`]). diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index ee2b6ea83..d5ed1ade2 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -453,8 +453,8 @@ fn listed(overrides: &Overrides, e: &Entry) -> bool { /// Commands whose params are all optional that do nothing, or fail, when a key press runs them /// with none, and that no item of the menu bar runs: Transparency and Opacity Mask Options need /// a value to set, Align and Distribute a direction, Delete Swatch and Move Swatch a swatch name, -/// and the library and data set loaders a file, which their panel menu items ask for. -const NEEDS_A_PARAM: [&str; 16] = [ +/// and the library, data set and text style loaders a file, which their panel menu items ask for. +const NEEDS_A_PARAM: [&str; 19] = [ "transparency.set", "transparency.setOpacityMask", "stroke.set", @@ -471,6 +471,9 @@ const NEEDS_A_PARAM: [&str; 16] = [ "brush.loadLibrary", "dataset.import", "variable.loadLibrary", + "charStyle.load", + "paraStyle.load", + "textStyle.loadAll", ]; /// The (label, params) of each menu bar item, by command id. @@ -700,6 +703,25 @@ pub fn import_json(v: &Value) -> Result<(String, Overrides), String> { Ok((v.get("set").and_then(Value::as_str).map_or(CUSTOM, set_name).to_string(), out)) } +/// Export Text: the set's name, then the dialog's Tools and Menu Commands lists in its order, one +/// row per line with the name and the shortcuts as the dialog shows them, separated by a tab. A +/// row with no shortcut ends after the tab. +pub fn export_text(set: &str, overrides: &Overrides) -> String { + let km = keymap_with(overrides); + let backtick = backtick_text(); + let shown_set = if PRESETS.contains(&set) || set == CUSTOM { tl!(set) } else { set }; + let mut out = format!("{}\n{} {shown_set}\n", tl!("Keyboard Shortcuts"), tl!("Set:")); + for (tools, heading) in [(true, "Tools"), (false, "Menu Commands")] { + out += &format!("\n{}\n", tl!(heading)); + for e in entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)) { + let name = if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) }; + let sc = dialog_shortcuts_with(&km, &e.key, backtick.as_deref()).unwrap_or_default(); + out += &format!("{name}\t{sc}\n"); + } + } + out +} + // ---------- UI commands ---------- pub fn open(app: &mut VectorcraftApp) { @@ -724,22 +746,35 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result { Ok(json!({"overrides": app.ui.shortcut_overrides.len()})) } +/// The string `p` gives for `key`, or `None` when `p` leaves it out or gives null. Any other +/// value, such as `5`, is an error: "`{key}` must be a string". +fn text_param(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Ok(Some(v.clone())), + Some(_) => Err(format!("`{key}` must be a string")), + } +} + /// `shortcuts.*` UI commands. `None` = not ours. pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { - let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let s = |k: &str| text_param(p, k); let r = match id { "edit.keyboardShortcuts" => { open(app); Ok(Value::Null) } "shortcuts.set" => { - let key = s("id").unwrap_or_default(); + let (key, shortcut, force) = match (s("id"), s("shortcut"), crate::menus::opt_bool(p, "force")) { + (Ok(key), Ok(shortcut), Ok(force)) => (key.unwrap_or_default(), shortcut, force.unwrap_or(false)), + (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => return Some(Err(e)), + }; let mut ov = app.ui.shortcut_overrides.clone(); let r = if p.get("shortcut").is_some_and(Value::is_null) { reset_one(&mut ov, &key); Ok(vec![]) } else { - assign(&mut ov, &key, s("shortcut").as_deref(), p.get("force").and_then(Value::as_bool).unwrap_or(false)) + assign(&mut ov, &key, shortcut.as_deref(), force) }; r.map(|removed| { app.ui.shortcut_overrides = ov; @@ -749,7 +784,10 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - let q = s("query").unwrap_or_default().to_lowercase(); + let q = match s("query") { + Ok(q) => q.unwrap_or_default().to_lowercase(), + Err(e) => return Some(Err(e)), + }; let km = keymap_with(&app.ui.shortcut_overrides); let ov = km.overrides(); Ok(Value::Array( @@ -770,23 +808,28 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option match s("name").as_deref().map(set_name).and_then(|n| preset(n).map(|p| (n.to_string(), p))) { - Some((n, p)) => { + "shortcuts.preset" => match s("name").map(|n| n.as_deref().map(set_name).and_then(|n| preset(n).map(|p| (n.to_string(), p)))) { + Ok(Some((n, p))) => { app.ui.shortcut_overrides = p; app.ui.shortcut_set = n; sync(&app.ui); Ok(Value::Null) } - None => Err(format!("unknown preset (one of {})", PRESETS.join(", "))), + Ok(None) => Err(format!("unknown preset (one of {})", PRESETS.join(", "))), + Err(e) => Err(e), }, "shortcuts.export" => { + let path = match s("path") { + Ok(path) => path, + Err(e) => return Some(Err(e)), + }; let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides); let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default(); let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Shortcut sets", &["json"])], ..crate::FilePick::named("VectorCraft Shortcuts.json") }; - let path = s("path").or_else(|| crate::picks::save(app, &pick)); + let path = path.or_else(|| crate::picks::save(app, &pick)); match path { Some(path) => match app.services.write.as_mut() { Some(w) => w(&path, &bytes).map(|_| json!({"path": path})), @@ -800,15 +843,44 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { + let path = match p.get("path") { + None | Some(Value::Null) => None, + Some(Value::String(path)) => Some(path.clone()), + Some(_) => { + let key = "path"; + return Some(Err(format!("`{key}` must be a string"))); + } + }; + let text = export_text(&app.ui.shortcut_set, &app.ui.shortcut_overrides); + let pick = + crate::FilePick { filters: vec![crate::FilePick::filter("Text", &["txt"])], ..crate::FilePick::named("VectorCraft Shortcuts.txt") }; + match path.or_else(|| crate::picks::save(app, &pick)) { + Some(path) => match app.services.write.as_mut() { + Some(w) => w(&path, text.as_bytes()).map(|_| json!({"path": path})), + None => Err("no file writer".into()), + }, + // Its save dialog is open: the file is written when it answers. + None if app.file_dialog_open() => Err("cancelled".into()), + None => match app.services.download.as_mut() { + Some(dl) => dl("VectorCraft Shortcuts.txt", text.as_bytes()).map(|()| Value::Null), + None => Ok(json!({"text": text})), + }, + } + } "shortcuts.import" => { let data = if let Some(v) = p.get("data") { Ok(v.clone()) } else { + let path = match s("path") { + Ok(path) => path, + Err(e) => return Some(Err(e)), + }; let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Shortcut sets", crate::dialogs::shortcut_import::IMPORT_EXTS)], ..Default::default() }; - let path = s("path").or_else(|| crate::picks::open(app, &pick)); + let path = path.or_else(|| crate::picks::open(app, &pick)); // A `.kys` file is read by Import Shortcut Set, up to its reader's cap. if let Some(path) = path.as_deref().filter(|p| crate::dialogs::shortcut_import::is_kys(p)) { return Some(crate::dialogs::shortcut_import::command(app, &json!({ "path": path }))); @@ -947,6 +1019,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if ui.button(tl!("Export…")).clicked() { d.fields.insert("__export".into(), json!(true)); } + if ui.button(tl!("Export Text…")).clicked() { + d.fields.insert("__exportText".into(), json!(true)); + } if ui.button(tl!("Reset to Defaults")).clicked() { km.to_mut().reset_all(); d.fields.insert("set".into(), json!(PRESETS[0])); @@ -1090,6 +1165,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } let import = d.fields.remove("__import").is_some(); let export = d.fields.remove("__export").is_some(); + let export_text = d.fields.remove("__exportText").is_some(); app.ui.dialog = Some(d); // Their file dialogs, shown off the UI thread, import into or export from the dialog as it is // when they answer. @@ -1099,6 +1175,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if export { crate::picks::button(app, export_from_dialog); } + if export_text { + crate::picks::button(app, export_text_from_dialog); + } if cancel { app.ui.dialog = None; } else if ok && let Err(e) = confirm(app) { @@ -1137,6 +1216,16 @@ fn import_into_dialog(app: &mut VectorcraftApp) { /// The dialog's Export…: its working set, as edited so far. fn export_from_dialog(app: &mut VectorcraftApp) { + export_dialog_set(app, "shortcuts.export"); +} + +/// The dialog's Export Text…: its working set, as edited so far, as a text file. +fn export_text_from_dialog(app: &mut VectorcraftApp) { + export_dialog_set(app, "shortcuts.exportText"); +} + +/// Runs `command` on the dialog's working set and set name. +fn export_dialog_set(app: &mut VectorcraftApp, command: &str) { if !shortcuts_dialog(app) { app.status(crate::picks::dialog_gone()); return; @@ -1144,7 +1233,7 @@ fn export_from_dialog(app: &mut VectorcraftApp) { let (ov, set) = app.ui.dialog.as_ref().map(|d| (dialog_overrides(d), d.str("set"))).unwrap_or_default(); let saved = std::mem::replace(&mut app.ui.shortcut_overrides, ov); let saved_set = std::mem::replace(&mut app.ui.shortcut_set, set); - if let Some(Err(e)) = run_command(app, "shortcuts.export", &json!({})) { + if let Some(Err(e)) = run_command(app, command, &json!({})) { app.status(e); } app.ui.shortcut_overrides = saved; @@ -1453,6 +1542,82 @@ mod tests { assert!(import_json(&json!({})).is_err()); } + /// An Export Text line: the row's name as the dialog shows it, a tab and `shortcut`. + fn text_row(key: &str, shortcut: &str) -> String { + let e = entry(key).unwrap_or_else(|| panic!("no entry {key}")); + if e.is_tool || e.group.is_empty() { format!("{}\t{shortcut}", e.label) } else { format!("{} › {}\t{shortcut}", e.group, e.label) } + } + + /// Export Text writes the set's name, then the Tools and the Menu Commands rows in the dialog's + /// order, each with its shortcut as the dialog shows it. + #[test] + fn export_text_lists_the_dialogs_rows_with_their_shortcuts() { + let mut ov = Overrides::new(); + crate::keymap::set_override(&mut ov, "edit.preferences", "Ctrl+Alt+Shift+9"); + let text = export_text("VectorCraft Defaults", &ov); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[..4], ["Keyboard Shortcuts", "Set: VectorCraft Defaults", "", "Tools"]); + let menu_at = lines.iter().position(|l| *l == "Menu Commands").expect("a Menu Commands section"); + assert_eq!(lines[menu_at - 1], "", "a blank line before Menu Commands"); + let km = keymap_with(&ov); + let names = |tools: bool| -> Vec { + entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)).map(|e| text_row(&e.key, "")).collect() + }; + let names_of = |rows: &[&str]| -> Vec { rows.iter().map(|l| format!("{}\t", l.split('\t').next().unwrap_or_default())).collect() }; + assert_eq!(names_of(&lines[4..menu_at - 1]), names(true), "the Tools rows, in the dialog's order"); + assert_eq!(names_of(&lines[menu_at + 1..]), names(false), "the Menu Commands rows, in the dialog's order"); + let pen = menus::pretty_shortcut(default_of("tool:pen").expect("the Pen tool has a default key")); + assert!(lines[4..menu_at].contains(&text_row("tool:pen", &pen).as_str()), "{text}"); + let prefs = menus::pretty_shortcut(&effective_with(&km, "edit.preferences").expect("the shortcut set")); + assert!(prefs.ends_with('9'), "{prefs}"); + assert!(lines[menu_at..].contains(&text_row("edit.preferences", &prefs).as_str()), "{text}"); + assert!(text.ends_with('\n')); + } + + /// A command without a shortcut is listed with nothing after the tab: one without a default key + /// and one whose shortcut the set clears. + #[test] + fn export_text_lists_a_command_without_a_shortcut_with_an_empty_shortcut() { + let mut ov = Overrides::new(); + crate::keymap::set_override(&mut ov, "edit.preferences", ""); + assert!(default_of("align.horizontalLeft").is_none()); + let text = export_text(CUSTOM, &ov); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[1], "Set: Custom"); + for key in ["align.horizontalLeft", "edit.preferences"] { + assert!(lines.contains(&text_row(key, "").as_str()), "{key}: {text}"); + } + } + + /// `shortcuts.exportText` writes the set's name as the set was named, and a path that can't be + /// written or isn't a string gives an error and writes nothing. + #[test] + fn export_text_command_writes_the_sets_name_and_rejects_a_bad_path() { + type Files = std::rc::Rc>>; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let written: Files = Default::default(); + let w = written.clone(); + app.services.write = Some(Box::new(move |path: &str, b: &[u8]| { + if path.starts_with("/missing/") { + return Err(format!("can't write {path}")); + } + w.borrow_mut().push((path.to_string(), String::from_utf8(b.to_vec()).map_err(|e| e.to_string())?)); + Ok(()) + })); + app.run("shortcuts.import", json!({"data": export_json("Studio", &Overrides::new())})).unwrap(); + assert_eq!(app.run("shortcuts.exportText", json!({"path": "/sets/Studio.txt"})), Ok(json!({"path": "/sets/Studio.txt"}))); + assert_eq!( + app.run("shortcuts.exportText", json!({"path": "/missing/dir/Studio.txt"})), + Err("can't write /missing/dir/Studio.txt".to_string()) + ); + assert_eq!(app.run("shortcuts.exportText", json!({"path": 5})), Err("`path` must be a string".to_string())); + let files = written.borrow(); + let [(path, text)] = files.as_slice() else { panic!("{files:?}") }; + assert_eq!(path, "/sets/Studio.txt"); + assert!(text.starts_with("Keyboard Shortcuts\nSet: Studio\n\nTools\n"), "{text}"); + assert_eq!(*text, export_text("Studio", &Overrides::new())); + } + /// The dialog's Import… leaves an action its function key when the imported set gives the key /// to a command, through both Cancel and OK. #[test] @@ -1690,4 +1855,28 @@ mod tests { assign(&mut ov, "object.isolate", Some("Cmd+Alt+Shift+F11"), false).unwrap(); assert_eq!(effective_in(&ov, "object.isolate").as_deref(), Some("Cmd+Alt+Shift+F11")); } + + /// `shortcuts.set`, `shortcuts.list` and `shortcuts.preset` reject a supplied param of the wrong + /// type with an error naming it, and change nothing; an omitted or null param keeps its default. + #[test] + fn shortcut_commands_reject_a_param_of_the_wrong_type() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let set = app.ui.shortcut_set.clone(); + for (id, p, err) in [ + ("shortcuts.set", json!({"id": 5, "shortcut": "Cmd+Alt+Shift+F11"}), "`id` must be a string"), + ("shortcuts.set", json!({"id": "object.isolate", "shortcut": 7}), "`shortcut` must be a string"), + ("shortcuts.set", json!({"id": "object.isolate", "shortcut": "Cmd+Alt+Shift+F11", "force": "yes"}), "`force` must be true or false"), + ("shortcuts.list", json!({"query": 5}), "`query` must be a string"), + ("shortcuts.preset", json!({"name": true}), "`name` must be a string"), + ] { + assert_eq!(app.run(id, p.clone()), Err(err.to_string()), "{id} {p}"); + assert!(app.ui.shortcut_overrides.is_empty(), "{id} {p}"); + assert_eq!(app.ui.shortcut_set, set, "{id} {p}"); + } + let all = app.run("shortcuts.list", json!({})).unwrap(); + assert_eq!(app.run("shortcuts.list", json!({"query": null})), Ok(all)); + let r = app.run("shortcuts.set", json!({"id": "object.isolate", "shortcut": "Cmd+Alt+Shift+F11", "force": null})).unwrap(); + assert_eq!(r["shortcut"], "Cmd+Alt+Shift+F11"); + assert!(app.run("shortcuts.preset", json!({"name": null})).unwrap_err().starts_with("unknown preset")); + } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f6ceaec9d..713cf5dd4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -11,10 +11,12 @@ use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey}; use crate::VectorcraftApp; +mod held_tool; mod paste; mod presentation; mod typing; +pub(crate) use held_tool::{HeldTool, note_tool_use}; use paste::unclip_chords; pub(crate) use paste::{PasteChord, paste_command}; pub(crate) use presentation::presentation_step; @@ -186,7 +188,10 @@ 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); - if !(ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT) { + 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); + if !focused { crate::ime::controls_without_editor(app, ctx); return; } @@ -211,7 +216,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Escape closes a dropdown list or popover open in the dialog (each closes itself on it) and // leaves the dialog open. let popup = app.ui.dialog.as_ref().is_some_and(|d| egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d)); - if !popup && pressed_anew(ctx, Key::Escape) { + // Escape in Preferences' search field with text clears the field first. + if !popup && pressed_anew(ctx, Key::Escape) && !crate::prefs_dialog::clear_search_on_escape(app, ctx) { crate::dialogs::cancel(app); app.ui.palette_open = false; } @@ -236,8 +242,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { let composing = app.session.tool_composing(); // Tool keys first (Enter/Escape end paths; arrows change polygon sides while dragging). let busy = app.session.tool_busy(); - // Enter in the Type tool types a line break, so it goes to the tool with the frame's text, - // below: the loop then skips Enter, the first key. + // Enter in the Type tool types a line break (Cmd+Enter ends editing), so it goes to the tool + // with the frame's text, below: the loop then skips Enter, the first key. let enter_types = app.session.tool_wants_text(); for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)].into_iter().skip(usize::from(enter_types)) { if !typing && !composing && !take_claimed_repeat(app, ctx, k, tk, view) && pressed_anew(ctx, k) { @@ -316,6 +322,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)); } } + held_tool::note_enter_dialog(app, k); } } if typing { @@ -479,26 +486,26 @@ fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). - // A text's key is the press that typed it ([`typing_presses`]). - let events: Vec<(String, Modifiers, Option)> = ctx.input(|i| { + // A text's key is the press that typed it ([`typing_presses`]), and the text repeats with it. + let events: Vec<(String, Modifiers, Option, bool)> = ctx.input(|i| { let typed_by = typing_presses(&i.events); i.events .iter() .zip(typed_by) .filter_map(|(e, by)| match e { egui::Event::Text(t) => { - let (physical, key) = match by.and_then(|k| i.events.get(k)) { - Some(egui::Event::Key { key, physical_key, .. }) => (*physical_key, Some(*key)), - _ => (None, None), + let (physical, key, repeat) = match by.and_then(|k| i.events.get(k)) { + Some(egui::Event::Key { key, physical_key, repeat, .. }) => (*physical_key, Some(*key), *repeat), + _ => (None, None, false), }; - Some((latin(t, physical), i.modifiers, key)) + Some((latin(t, physical), i.modifiers, key, repeat)) } _ => None, }) .collect() }); let keymap = crate::shortcut_editor::current(); - for (text, m, pressed) in events { + for (text, m, pressed, repeat) in events { if m.command || m.alt || m.ctrl { continue; } @@ -520,7 +527,7 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // tools. let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; match hit.id.strip_prefix("tool:") { - Some(tool) => app.select_tool(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!({})); diff --git a/crates/ui-egui/src/shortcuts/held_tool.rs b/crates/ui-egui/src/shortcuts/held_tool.rs new file mode 100644 index 000000000..a3a809b96 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/held_tool.rs @@ -0,0 +1,154 @@ +//! A single-key tool shortcut held down (General › Hold a Tool Shortcut to Use the Tool +//! Temporarily, `holdToolKeys`): the tool it chose stays until the key is let go, and then the tool +//! active before comes back. A quick press chooses the tool for good. + +use super::{Key, VectorcraftApp}; + +/// A key let go this many seconds or more after its press returns to the tool active before it. +/// VectorCraft's own value. +pub(crate) const HOLD_SECONDS: f64 = 0.25; + +/// A tool shortcut's key held down since it chose its tool. +pub(crate) struct HeldTool { + /// The key that chose the tool. + key: Key, + /// The viewport that saw the press: other viewports do not see the key. + viewport: egui::ViewportId, + /// The tool the key chose. + tool: &'static str, + /// The tool to go back to. + previous: &'static str, + /// When the key went down (`egui::InputState::time`). + since: f64, + /// The tool took a drag, a click or a dialog while the key was down. + used: bool, + /// When a frame first saw the key let go. The decision can come in a later frame (after a + /// drag, a dialog or a press of the same frame), and it uses this time. + released: Option, +} + +/// Chooses `tool` for a press of its single-key shortcut `key` (`None`: no key press typed it). A +/// press that changes the tool, with the preference on, starts a hold. A hold started while another +/// key is held goes on top of it: letting go of the later key returns to the earlier key's tool, +/// and letting go of the earlier key then returns to the tool before both. With the preference on, +/// a key's repeats choose nothing: a key held down chooses its tool once. With it off, each repeat +/// chooses the tool again. +pub(super) fn choose(app: &mut VectorcraftApp, ctx: &egui::Context, tool: &str, key: Option, repeat: bool) { + if repeat && app.session.prefs.hold_tool_keys { + return; + } + let previous = app.session.tool_id(); + // `select_tool` ends the holds of a tool chosen any other way; the keys held stay held here. + let holds = std::mem::take(&mut app.held_tool); + app.select_tool(tool); + app.held_tool = holds; + let chosen = app.session.tool_id(); + if chosen == previous { + return; + } + let viewport = ctx.viewport_id(); + let (down, now) = key.map_or((false, 0.0), |k| ctx.input(|i| (i.key_down(k), i.time))); + match key { + // A key let go in the frame that pressed it was a quick press. + Some(key) if down && app.session.prefs.hold_tool_keys && chosen == tool => { + app.held_tool.retain(|h| h.viewport == viewport && h.key != key); + app.held_tool.push(HeldTool { key, viewport, tool: chosen, previous, since: now, used: false, released: None }); + } + _ => app.held_tool.clear(), + } +} + +/// A press that reaches the active tool, a drag of the Zoom, Hand or Rotate View tool, or the +/// tool's dialog opened with Enter, uses the tool of the key held last when that tool is active, +/// also when that and the key's release come between two frames of [`follow`]. +pub(crate) fn note_tool_use(app: &mut VectorcraftApp) { + let active = app.session.tool_id(); + if let Some(held) = app.held_tool.last_mut().filter(|h| h.tool == active) { + held.used = true; + } +} + +/// After the tool takes `key`: a dialog that Enter left open is the tool's, so it uses the tool. +pub(super) fn note_enter_dialog(app: &mut VectorcraftApp, key: Key) { + if key == Key::Enter && app.ui.dialog.is_some() { + note_tool_use(app); + } +} + +/// Once per frame of the viewport that saw the press: when the key held last is let go, goes back +/// to the tool active before it, unless the press was quick and the tool was not used. A key let +/// go during a drag, with the button held or with a dialog open, goes back once that ends. Choosing +/// another tool, a quick press, turning the preference off or the window losing the keyboard ends +/// every hold without going back. A frame of another viewport that has the keyboard also ends them. +pub(super) fn follow(app: &mut VectorcraftApp, ctx: &egui::Context, focused: bool) { + // Every key let go is timed in the frame that first sees it up, under the key held last too. + let (viewport, time) = (ctx.viewport_id(), ctx.input(|i| i.time)); + for held in app.held_tool.iter_mut().filter(|h| h.viewport == viewport && h.released.is_none()) { + if !ctx.input(|i| i.key_down(held.key)) { + held.released = Some(time); + } + } + // Each pass looks at the key held last; one that goes back hands over to the key under it. + loop { + let gesture = app.session.tool_busy() || app.session.pointer_held(); + // A dialog, or a canvas drag that does not reach the tool (a pan, zoom or turn of the view), + // defers the decision. + let dialog = app.ui.dialog.is_some() || crate::canvas::dragging(ctx); + let active = app.session.tool_id(); + let on = app.session.prefs.hold_tool_keys; + let Some(held) = app.held_tool.last_mut() else { return }; + if !on { + app.held_tool.clear(); + return; + } + if held.viewport != ctx.viewport_id() { + // This viewport has the keyboard, so the viewport that saw the press lost it. + if focused { + app.held_tool.clear(); + } + return; + } + if !focused { + app.held_tool.clear(); + return; + } + let (down, now) = ctx.input(|i| (i.key_down(held.key), i.time)); + if gesture || dialog { + // A gesture of the held tool with the key down uses the tool; a gesture lent to a + // selection tool does not. A dialog uses it only when Enter opened it, and a view drag + // when it is the held tool's ([`note_tool_use`]). + held.used |= gesture && down && active == held.tool; + return; + } + if active != held.tool { + app.held_tool.clear(); + return; + } + if down { + return; + } + // The canvas takes a pointer press of the same frame, and the tool an Enter press, after + // this: the decision waits for the next frame, measured from this one. + let pressed = ctx.input(|i| { + i.events + .iter() + .any(|e| matches!(e, egui::Event::PointerButton { pressed: true, .. } | egui::Event::Key { key: Key::Enter, pressed: true, .. })) + }); + let released = held.released.unwrap_or(now); + if pressed && released == now { + ctx.request_repaint(); + return; + } + let back = held.used || released - held.since >= HOLD_SECONDS; + let previous = held.previous; + if !back { + app.held_tool.clear(); + return; + } + app.held_tool.pop(); + app.return_to_tool(previous); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/shortcuts/held_tool/tests.rs b/crates/ui-egui/src/shortcuts/held_tool/tests.rs new file mode 100644 index 000000000..e2217b822 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/held_tool/tests.rs @@ -0,0 +1,462 @@ +use serde_json::json; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use super::super::handle; +use super::*; + +/// A keyboard on one context, so egui keeps the keys held down from frame to frame and marks a +/// held key's later presses as repeats, with the frame times the test sets. +struct Keyboard { + ctx: egui::Context, +} + +impl Keyboard { + fn new() -> Self { + Self { ctx: egui::Context::default() } + } + + /// One frame at `time` seconds delivering `events`; with `field`, a text field that takes the + /// keyboard is drawn after the shortcuts. + fn frame(&self, app: &mut VectorcraftApp, time: f64, events: Vec, field: Option<&mut String>) { + self.frame_with_focus(app, time, events, field, true); + } + + /// [`Self::frame`], with the window having the keyboard (`focused`) or not. + fn frame_with_focus(&self, app: &mut VectorcraftApp, time: f64, events: Vec, mut field: Option<&mut String>, focused: bool) { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let mut out = self.ctx.run_ui(egui::RawInput { events, screen_rect, time: Some(time), focused, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + handle(app, ui.ctx()); + if let Some(text) = field.as_deref_mut() { + ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("held-tool-field"))).request_focus(); + } + }); + out.textures_delta.clear(); + } + + /// One frame at `time` seconds delivering `events`, with the canvas drawn after the shortcuts. + fn on_canvas(&self, app: &mut VectorcraftApp, time: f64, events: Vec) { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let mut out = self.ctx.run_ui(egui::RawInput { events, screen_rect, time: Some(time), focused: true, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + handle(app, ui.ctx()); + crate::canvas::show(app, ui); + }); + out.textures_delta.clear(); + } + + /// `key` pressed (or repeated while it is down) at `time`, typing `letter`. + fn press(&self, app: &mut VectorcraftApp, time: f64, key: Key, letter: &str) { + let press = egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + self.frame(app, time, vec![press, egui::Event::Text(letter.into())], None); + } + + fn release(&self, app: &mut VectorcraftApp, time: f64, key: Key) { + let release = egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + self.frame(app, time, vec![release], None); + } + + fn press_s(&self, app: &mut VectorcraftApp, time: f64) { + self.press(app, time, Key::S, "s"); + } + + fn release_s(&self, app: &mut VectorcraftApp, time: f64) { + self.release(app, time, Key::S); + } +} + +/// A document with a selected rectangle and the Pen tool chosen. +fn pen_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::shortcut_editor::sync(&app.ui); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap(); + app.select_tool("pen"); + app +} + +/// S held from 0 s, its repeats at `repeats`, let go at `release`. +fn hold_s(app: &mut VectorcraftApp, keys: &Keyboard, repeats: &[f64], release: f64) { + keys.press_s(app, 0.0); + assert_eq!(app.session.tool_id(), "scale", "the press chooses Scale"); + for &t in repeats { + keys.press_s(app, t); + assert_eq!(app.session.tool_id(), "scale", "a repeat at {t} s keeps Scale"); + } + keys.release_s(app, release); +} + +/// A quick press of S chooses Scale for good. +#[test] +fn a_quick_press_of_a_tool_key_chooses_the_tool_for_good() { + let mut app = pen_app(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[], HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "scale"); + keys.frame(&mut app, 5.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "Scale stays chosen"); + assert!(app.held_tool.is_empty()); +} + +/// A press and release of S in one frame is a quick press, also when the next frame comes long +/// after it. +#[test] +fn a_press_and_release_in_one_frame_chooses_the_tool_for_good() { + let mut app = pen_app(); + let keys = Keyboard::new(); + let key = |pressed| egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.0, vec![key(true), egui::Event::Text("s".into()), key(false)], None); + keys.frame(&mut app, 5.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// S held past the threshold, with its key repeats, and let go returns to the Pen. +#[test] +fn a_held_tool_key_returns_to_the_previous_tool_when_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[0.1, 0.2, 0.3, 0.4], 0.5); + assert_eq!(app.session.tool_id(), "pen"); + assert!(app.held_tool.is_empty()); +} + +/// S held from the Pen and then V held: letting go of V returns to Scale, and letting go of S +/// then returns to the Pen. +#[test] +fn overlapping_held_tool_keys_return_one_at_a_time() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.1, Key::V, "v"); + assert_eq!(app.session.tool_id(), "selection"); + keys.release(&mut app, 0.4, Key::V); + assert_eq!(app.session.tool_id(), "scale", "letting go of V returns to Scale while S is down"); + keys.release_s(&mut app, 0.6); + assert_eq!(app.session.tool_id(), "pen", "letting go of S returns to the Pen"); + assert!(app.held_tool.is_empty()); +} + +/// S let go during a Scale drag returns to the Pen once the drag ends, also when the key was let go +/// before the threshold. +#[test] +fn a_tool_key_let_go_during_a_drag_returns_after_the_drag() { + for release in [HOLD_SECONDS / 2.0, 1.0] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 260.0, 190.0), view).unwrap(); + assert!(app.session.tool_busy(), "Scale is dragging"); + keys.frame(&mut app, release / 2.0, vec![], None); + keys.release_s(&mut app, release); + assert_eq!(app.session.tool_id(), "scale", "let go at {release} s: Scale stays during the drag"); + keys.frame(&mut app, release + 0.1, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "let go at {release} s: still dragging"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 260.0, 190.0), view).unwrap(); + keys.frame(&mut app, release + 0.2, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "let go at {release} s: the Pen is back after the drag"); + } +} + +/// A click on the canvas between two frames while S is down uses Scale, so letting go of S before +/// the threshold returns to the Pen. +#[test] +fn a_click_between_frames_counts_as_using_the_held_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.release_s(&mut app, HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "pen"); +} + +/// A Cmd/Ctrl click while S is down goes to the selection tool lent to Scale, so it does not use +/// Scale: letting go of S before the threshold keeps Scale, whether or not a frame comes during +/// the click. +#[test] +fn a_click_lent_to_a_selection_tool_does_not_use_the_held_tool() { + for frame_during_click in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let mods = vectorcraft_tools::Mods { cmd: true, ..Default::default() }; + assert!(app.session.lent_tool_id(&mods).is_some(), "Scale lends a selection tool to a Cmd press"); + let view = app.view_info(); + let event = |kind| PointerEvent { mods, ..PointerEvent::new(kind, 150.0, 125.0) }; + crate::canvas::dispatch(&mut app, &event(PointerKind::Down), view); + if frame_during_click { + keys.frame(&mut app, HOLD_SECONDS / 4.0, vec![], None); + } + crate::canvas::dispatch(&mut app, &event(PointerKind::Up), view); + keys.release_s(&mut app, HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "scale", "frame during the click: {frame_during_click}"); + assert!(app.held_tool.is_empty()); + } +} + +/// A pan with the Hand tool while H is down uses the Hand: letting go of H quickly after the pan +/// returns to the Pen, and letting go of H during the pan returns once the pan ends. +#[test] +fn a_view_tool_drag_counts_as_using_the_held_tool() { + for let_go_during_pan in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.on_canvas(&mut app, 0.0, vec![]); + let at = app.canvas_rect.expect("the canvas was drawn").center(); + let to = at + egui::vec2(30.0, 20.0); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let key = |pressed| egui::Event::Key { key: Key::H, physical_key: Some(Key::H), pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.on_canvas(&mut app, 0.01, vec![egui::Event::PointerMoved(at), key(true), egui::Event::Text("h".into())]); + assert_eq!(app.session.tool_id(), "hand", "let go during the pan: {let_go_during_pan}"); + keys.on_canvas(&mut app, 0.02, vec![button(at, true)]); + keys.on_canvas(&mut app, 0.03, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::dragging(&keys.ctx), "the Hand pans"); + if let_go_during_pan { + keys.on_canvas(&mut app, 0.05, vec![key(false)]); + assert_eq!(app.session.tool_id(), "hand", "the Hand stays during the pan"); + keys.on_canvas(&mut app, 0.06, vec![button(to, false)]); + } else { + keys.on_canvas(&mut app, 0.05, vec![button(to, false)]); + keys.on_canvas(&mut app, 0.06, vec![key(false)]); + } + keys.on_canvas(&mut app, 0.07, vec![]); + assert_eq!(app.session.tool_id(), "pen", "let go during the pan: {let_go_during_pan}"); + assert!(app.held_tool.is_empty()); + } +} + +/// A pointer press in the frame that lets go of S: a press that reaches Scale on the canvas returns +/// to the Pen, and a press elsewhere (no canvas press follows) keeps Scale, also when the next frame +/// comes late. +#[test] +fn a_press_in_the_frame_that_lets_go_waits_for_the_canvas() { + for canvas in [true, false] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let at = egui::pos2(150.0, 125.0); + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.1, vec![egui::Event::PointerMoved(at), press, release], None); + assert_eq!(app.session.tool_id(), "scale", "canvas {canvas}: no decision before the canvas"); + if canvas { + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + } + keys.frame(&mut app, 2.0, vec![], None); + assert_eq!(app.session.tool_id(), if canvas { "pen" } else { "scale" }, "canvas {canvas}"); + } +} + +/// S and then V pressed quickly, both let go in the frame of a canvas click with the Selection tool: +/// V was used, so Scale comes back, and S was a quick press, so Scale stays, also when the next +/// frame comes late. +#[test] +fn keys_let_go_together_are_timed_in_the_frame_that_lets_them_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.02, Key::V, "v"); + let at = egui::pos2(150.0, 125.0); + let up = |key| egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.05, vec![egui::Event::PointerMoved(at), press, up(Key::V), up(Key::S)], None); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.frame(&mut app, 2.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); + assert!(app.held_tool.is_empty()); +} + +/// With S held and then V held, a click with the Selection tool uses Selection only: V let go +/// quickly returns to Scale, and S let go quickly then keeps Scale. +#[test] +fn a_click_uses_only_the_tool_of_the_key_held_last() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.02, Key::V, "v"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.release(&mut app, 0.05, Key::V); + assert_eq!(app.session.tool_id(), "scale", "V was used, so Scale is back"); + keys.release_s(&mut app, 0.1); + assert_eq!(app.session.tool_id(), "scale", "Scale was not used, so it stays"); +} + +/// Enter with S down opens the Scale dialog, and with V down the Move dialog; the key let go quickly +/// while the dialog is open returns to the Pen once the dialog closes. +#[test] +fn a_dialog_opened_during_the_hold_counts_as_using_the_tool() { + for (key, letter, tool, dialog) in [(Key::S, "s", "scale", "scale"), (Key::V, "v", "selection", "move")] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press(&mut app, 0.0, key, letter); + let enter = + egui::Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.05, vec![enter], None); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialog), "Enter opens the {dialog} dialog"); + keys.release(&mut app, 0.1, key); + assert_eq!(app.session.tool_id(), tool, "{tool} stays while its dialog is open"); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 0.15, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "after the {dialog} dialog"); + } +} + +/// Enter pressed in the frame that lets go of S after the threshold opens the Scale dialog, and +/// Scale stays until the dialog closes. +#[test] +fn enter_in_the_frame_that_lets_go_reaches_the_held_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let enter = egui::Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 1.0, vec![release, enter], None); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("scale"), "Enter opens the Scale dialog"); + keys.frame(&mut app, 1.1, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "Scale stays while its dialog is open"); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 1.2, vec![], None); + assert_eq!(app.session.tool_id(), "pen"); +} + +/// Another dialog (Preferences) opened while S is down and S let go quickly while it is open: Scale +/// was not used, so it stays once the dialog closes. +#[test] +fn another_dialog_opened_during_the_hold_does_not_use_the_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + crate::prefs_dialog::open(&mut app, None); + keys.frame(&mut app, 0.05, vec![], None); + keys.release_s(&mut app, 0.1); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 0.15, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// S typed and held in a text field types its letter and chooses no tool. +#[test] +fn a_tool_key_held_in_a_text_field_types_and_keeps_the_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + let mut text = String::new(); + keys.frame(&mut app, 0.0, vec![], Some(&mut text)); + let press = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.1, vec![press.clone(), egui::Event::Text("s".into())], Some(&mut text)); + keys.frame(&mut app, 0.6, vec![press, egui::Event::Text("s".into())], Some(&mut text)); + keys.frame(&mut app, 1.0, vec![release], Some(&mut text)); + assert_eq!(text, "ss"); + assert_eq!(app.session.tool_id(), "pen"); + assert!(app.held_tool.is_empty()); +} + +/// With General › Hold a Tool Shortcut to Use the Tool Temporarily off, a held S chooses Scale for +/// good, as a quick press does. +#[test] +fn with_the_preference_off_a_held_tool_key_chooses_the_tool_for_good() { + let mut app = pen_app(); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[0.1, 0.2, 0.3, 0.4], 0.5); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// With the preference off, each repeat of a held S chooses Scale again, as before: a flyout opened +/// in between closes. +#[test] +fn with_the_preference_off_each_repeat_chooses_the_tool_again() { + let mut app = pen_app(); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.ui.flyout = Some(0); + keys.press_s(&mut app, 0.3); + assert_eq!(app.ui.flyout, None, "the repeat chose Scale again"); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// A hold whose viewport stopped drawing ends when another viewport has the keyboard or the +/// preference is turned off, and stays while the other viewport does not have the keyboard. +#[test] +fn a_hold_of_another_viewport_ends_when_this_one_has_the_keyboard() { + let other = egui::ViewportId::from_hash_of("held-tool-other"); + let hold = || HeldTool { key: Key::S, viewport: other, tool: "scale", previous: "pen", since: 0.0, used: false, released: None }; + let mut app = pen_app(); + app.select_tool("scale"); + let keys = Keyboard::new(); + app.held_tool.push(hold()); + keys.frame_with_focus(&mut app, 0.5, vec![], None, false); + assert_eq!(app.held_tool.len(), 1, "a viewport without the keyboard leaves the hold alone"); + keys.frame(&mut app, 1.0, vec![], None); + assert!(app.held_tool.is_empty(), "a viewport with the keyboard ends the hold"); + assert_eq!(app.session.tool_id(), "scale"); + app.held_tool.push(hold()); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + keys.frame_with_focus(&mut app, 1.5, vec![], None, false); + assert!(app.held_tool.is_empty(), "turning the preference off ends the hold"); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// Scale chosen again on the toolbar while S is held stays chosen when S is let go. +#[test] +fn the_held_tool_chosen_again_stays_when_the_key_is_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.select_tool("scale"); + // A tool chosen closes an open flyout: the repeats of S, still down, choose nothing. + app.ui.flyout = Some(0); + keys.press_s(&mut app, 0.3); + assert_eq!(app.ui.flyout, Some(0), "a repeat chooses no tool"); + keys.release_s(&mut app, 0.5); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// The window losing the keyboard while S is held, also during a drag, ends the hold: Scale stays. +#[test] +fn losing_the_keyboard_ends_the_hold() { + for drag in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + if drag { + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 260.0, 190.0), view).unwrap(); + } + keys.frame_with_focus(&mut app, 0.3, vec![egui::Event::WindowFocused(false)], None, false); + if drag { + app.session.pointer(&PointerEvent::new(PointerKind::Up, 260.0, 190.0), view).unwrap(); + } + keys.frame(&mut app, 0.5, vec![egui::Event::WindowFocused(true)], None); + keys.release_s(&mut app, 0.6); + assert_eq!(app.session.tool_id(), "scale", "drag {drag}: Scale stays"); + } +} + +/// Another tool chosen while S is held stays chosen when S is let go. +#[test] +fn a_tool_chosen_during_the_hold_stays_when_the_key_is_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.select_tool("rectangle"); + keys.frame(&mut app, 0.3, vec![], None); + keys.release_s(&mut app, 0.5); + assert_eq!(app.session.tool_id(), "rectangle"); +} diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 5fbffb533..b64dd4f7a 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1288,6 +1288,31 @@ fn holding_the_enter_that_confirms_a_dialog_types_no_line_break() { assert_ne!(text_and_layers(&mut app, text).0, before, "a new press types one"); } +/// A held Cmd+Enter ends text editing once. Its repeats add no line break and do not open the Move +/// dialog for the type it leaves selected; a new press does, as after Escape. +#[test] +fn holding_cmd_enter_ends_text_editing_once() { + let mut app = app_with_a_selected_rectangle(); + let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + let held = HeldKey::new(Key::Enter, Modifiers::COMMAND); + held.frame(&mut app, vec![]); + let before = text_and_layers(&mut app, text); + held.press(&mut app); + assert!(!app.session.tool_wants_text(), "the press ends editing"); + assert_eq!(app.session.tool_id(), "selection"); + for repeat in 0..4 { + held.press(&mut app); + assert_eq!(text_and_layers(&mut app, text), before, "repeat {repeat}: no line break"); + assert_eq!(open_dialog(&app), None, "repeat {repeat}: no dialog"); + } + held.release(&mut app); + held.press(&mut app); + assert_eq!(open_dialog(&app).as_deref(), Some("move"), "a new press opens Move"); +} + /// A release that never reaches the main window costs the next press: egui takes that press for a /// repeat, and its release clears the key, so the press after it acts again. #[test] diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs index cdd8c16f8..a3127adde 100644 --- a/crates/ui-egui/src/shortcuts/typing.rs +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -52,7 +52,7 @@ enum Seq { InterruptNavigation, InterruptCommand, ResetCompleted(egui::ImeResetId), - /// Enter, which types a line break. + /// Enter, which types a line break. Cmd+Enter (Ctrl+Enter on Windows and Linux) ends editing. Enter(Mods), /// An editing key with modifiers, an arrow, Home, End, Tab or a clipboard event. Edit(TextInput), @@ -74,7 +74,8 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { // characters in one frame interleaves correctly. Taken as two passes (all text, then the keys) // the deletes land after the inserts and the diacritics are lost. Enter, the other editing keys // and the clipboard events join them: ← or a paste lands between the texts around it, and each - // Enter types its own line break. Each handled event is removed from the input. + // Enter types its own line break (or, as Cmd+Enter, ends editing there). Each handled event is + // removed from the input. let seq = type_sequence(ctx); let viewport = ctx.viewport_id(); let mut old_composition_retired = false; @@ -177,7 +178,7 @@ fn type_sequence(ctx: &egui::Context) -> Vec { let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete }; seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() })); } - // A command bound to the chord (Cmd+Enter) runs instead of the line break, in the + // A command bound to the chord (Cmd+Enter) runs instead of ending editing, in the // shortcuts after this. egui::Event::Key { key: Key::Enter, pressed, modifiers: m, .. } if !chord_runs_a_command(Key::Enter, m, true) => { if pressed { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 14cd10d28..85f87b723 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -204,6 +204,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::links::ID, "Links", "link"), (crate::panels::asset_export::ID, "Asset Export", "share-2"), (crate::panels::css_properties::ID, "CSS Properties", "globe"), + (crate::panels::three_d::ID, "3D and Materials", "dc-cube"), (crate::panels::variables::ID, "Variables", "chart-column"), (crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"), (crate::addons::color::ID, "Color Effects", "sun"), @@ -370,6 +371,9 @@ pub struct UiState { #[serde(skip)] pub transient_focus: bool, pub control_bar: bool, + /// The Control bar is docked to the bottom of the window instead of the top + /// (`window.control.dock`). + pub control_bar_bottom: bool, pub toolbar: bool, pub toolbar_double: bool, /// Advanced toolbar (every tool group) instead of the categorized Basic toolbar. @@ -961,6 +965,7 @@ impl Default for UiState { transient_panel: None, transient_focus: false, control_bar: false, + control_bar_bottom: false, toolbar: true, toolbar_double: false, toolbar_advanced: false, diff --git a/crates/ui-egui/src/tests/nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs index a72e6cf6c..65bd01999 100644 --- a/crates/ui-egui/src/tests/nativemenu.rs +++ b/crates/ui-egui/src/tests/nativemenu.rs @@ -155,7 +155,8 @@ fn language_and_appearance_are_in_the_app_menu() { /// Everything the in-window bar lists is in the Mac menu bar, once: what moved to the app menu /// (About, Settings, Quit, Language, UI Brightness) leaves its other places, and Join Our Discord -/// stays under Help. +/// stays under Help. The Mac bar is laid out from the in-window bar's own read of the menus: Type › +/// Font and the plug-in rows come from process-wide state that a test running alongside can change. #[test] fn every_in_window_item_is_in_the_mac_menu_bar_once() { let app = app(true); @@ -171,7 +172,7 @@ fn every_in_window_item_is_in_the_mac_menu_bar_once() { n }; let in_window = native_menu::from_tree(&app, &menus::menu_tree()); - let mac = bar(&app); + let mac = native_menu::mac_layout(&app, &in_window, Lang::EN).bar; let (a, b) = (count(&in_window), count(&mac)); // The in-window application menu's commands, its submenus' included. let moved: Vec<&str> = MenuBar { menus: vec![in_window.menus[0].clone()] }.items().iter().filter_map(|it| it.command).collect(); @@ -225,19 +226,26 @@ fn a_command_keeps_a_system_key_it_is_bound_to() { } /// A native menu can't turn a plain item into a check item in place: opening a document or -/// selecting must not change the menus' structure, or every change rebuilds them. +/// selecting must not change the menus' structure, or every change rebuilds them. The four reads run +/// undisturbed: a font or plug-in that a test running alongside adds between two of them changes the +/// key. #[test] fn documents_and_selections_change_state_not_structure() { - let empty = native_menu::structure_key(&bar(&app(false))); - let mut a = app(true); - assert_eq!(native_menu::structure_key(&bar(&a)), empty, "a document with a selected rectangle"); - a.run("select.none", json!({})).unwrap(); - a.run("text.create", json!({"x": 50, "y": 50, "text": "Hi"})).unwrap(); - assert!(!a.session.active().unwrap().selection.objects.is_empty()); - assert_eq!(native_menu::structure_key(&bar(&a)), empty, "type selected"); - // A recent file is a new item: the menus are rebuilt. - crate::io::note_recent(&mut a, "/art/a.svg"); - assert_ne!(native_menu::structure_key(&bar(&a)), empty); + let (_m, (empty, drawn, typed, recent)) = undisturbed(|_| { + let key = |app: &VectorcraftApp| native_menu::structure_key(&bar(app)); + let empty = key(&app(false)); + let mut a = app(true); + let drawn = key(&a); + a.run("select.none", json!({})).unwrap(); + a.run("text.create", json!({"x": 50, "y": 50, "text": "Hi"})).unwrap(); + assert!(!a.session.active().unwrap().selection.objects.is_empty()); + let typed = key(&a); + crate::io::note_recent(&mut a, "/art/a.svg"); + (empty, drawn, typed, key(&a)) + }); + assert_eq!(drawn, empty, "a document with a selected rectangle"); + assert_eq!(typed, empty, "type selected"); + assert_ne!(recent, empty, "a recent file is a new item: the menus are rebuilt"); } #[test] diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 1cd02a9d2..8d475a917 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -159,6 +159,106 @@ fn typing_into_a_hex_field_commits_it() { } } +/// Keyboard focus selects a hex field's digits. A pasted code replaces them wherever the caret is, +/// hex digits that fit edit them in place, and a paste that is not a color leaves them as they were. +/// Typing digit by digit is not affected. +#[test] +fn a_paste_into_a_hex_field_replaces_or_edits_its_digits() { + let hex = Cell::new(None); + let hex_id = Cell::new(egui::Id::NULL); + let draw = |ui: &mut egui::Ui| { + let red = widgets::plain_field(ui, "red", 255.0, "", 0, 80.0); + hex_id.set(ui.scope_id().with("hex")); + hex.set(widgets::hex_field(ui, "hex", "FF8000")); + red + }; + let (tab, end) = (|| key(Key::Tab, Modifiers::NONE), || key(Key::End, Modifiers::NONE)); + let paste = |text: &str| vec![Event::Paste(text.into())]; + let digit = |text: &str| vec![Event::Text(text.into())]; + // Its name, the events after the field has the focus, the text they leave and what Tab commits. + type Case<'a> = (&'a str, Vec>, &'a str, Option<&'a str>); + let select_left = |n: usize| std::iter::once(end()).chain(std::iter::repeat_n(key(Key::ArrowLeft, Modifiers::SHIFT), n)).collect::>(); + let cases: [Case; 25] = [ + ("three selected, three digits", vec![select_left(3), paste("ABC")], "FF8ABC", Some("FF8ABC")), + ("three characters, caret, three digits", vec![digit("FF8"), paste("ABC")], "FF8ABC", Some("FF8ABC")), + ("five characters, two selected, three digits", vec![digit("12345"), select_left(2), paste("ABC")], "123ABC", Some("123ABC")), + ("two characters, caret, three digits", vec![digit("12"), paste("ABC")], "ABC", Some("AABBCC")), + ("four selected, three digits", vec![select_left(4), paste("0f0")], "0f0", Some("00ff00")), + ("selected digits", vec![paste("00FF00")], "00FF00", Some("00FF00")), + ("caret, a code", vec![vec![end()], paste("00FF00")], "00FF00", Some("00FF00")), + ("caret, a code with #", vec![vec![end()], paste("#00ff00")], "00ff00", Some("00ff00")), + ("caret, three digits", vec![vec![end()], paste("0f0")], "0f0", Some("00ff00")), + ("caret, a line break after the code", vec![vec![end()], paste("#00FF00\n")], "00FF00", Some("00FF00")), + ("caret, a word", vec![vec![end()], paste("hello")], "FF8000", None), + ("caret, too few digits", vec![vec![end()], paste("12")], "FF8000", None), + ("selected digits, too many digits", vec![paste("12345678")], "FF8000", None), + ("part selected, digits that fit", vec![select_left(2), paste("AB")], "FF80AB", Some("FF80AB")), + ("typed # and two digits, four digits", vec![digit("#12"), paste("3456")], "#123456", Some("123456")), + ("typed # and three digits, three digits", vec![digit("#FFF"), paste("ABC")], "#FFFABC", Some("FFFABC")), + ("typed # and two digits, three digits", vec![digit("#12"), paste("ABC")], "ABC", Some("AABBCC")), + ("typed #, two digits", vec![digit("#"), paste("12")], "#12", Some("12")), + ("empty field, two digits", vec![vec![key(Key::Backspace, Modifiers::NONE)], paste("12")], "12", Some("12")), + ("the same color in other letters", vec![paste("ff8000")], "ff8000", None), + ("typed digit by digit", ["0", "0", "F", "F", "0", "0"].map(digit).to_vec(), "00FF00", Some("00FF00")), + ("typed with #", vec![digit("#00FF00")], "#00FF00", Some("00FF00")), + ("typed in part", vec![digit("00")], "00", Some("00")), + ("typed three digits with #", vec![digit("#0f0")], "#0f0", Some("00ff00")), + ("typed three letters that are not digits", vec![digit("xyz")], "xyz", Some("xyz")), + ]; + for (name, events, shows, commits) in cases { + let mut f = Field::new(); + f.frame(vec![], &draw); + f.frame(vec![tab()], &draw); + f.frame(vec![tab()], &draw); + f.frame(vec![], &draw); + let selected = egui::TextEdit::load_state(&f.ctx, hex_id.get()).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); + assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 6)), "{name}: keyboard focus selects the digits"); + for events in events { + f.frame(events, &draw); + assert_eq!(hex.take(), None, "{name}: nothing is committed before Tab"); + } + assert_eq!(f.ctx.data(|d| d.get_temp::(hex_id.get())).as_deref(), Some(shows), "{name}: the text"); + f.frame(vec![tab()], &draw); + assert_eq!(hex.take().as_deref(), commits, "{name}: what Tab commits"); + } + // Escape drops a pasted code. + let mut f = Field::new(); + f.frame(vec![], &draw); + f.frame(vec![tab()], &draw); + f.frame(vec![tab()], &draw); + f.frame(paste("00FF00"), &draw); + assert_eq!(hex.take(), None); + f.frame(vec![key(Key::Escape, Modifiers::NONE)], &draw); + assert_eq!(hex.take(), None, "Escape commits nothing"); + f.frame(vec![], &draw); + assert_eq!(f.ctx.data(|d| d.get_temp::(hex_id.get())).as_deref(), Some("FF8000"), "the field shows its color again"); + // A press outside the field commits in the frame of the press, before the press acts. + let mut f = Field::new(); + f.frame(vec![], &draw); + f.frame(vec![tab()], &draw); + f.frame(vec![tab()], &draw); + f.frame(paste("00FF00"), &draw); + let away = Pos2::new(390.0, 190.0); + f.frame(vec![Event::PointerMoved(away)], &draw); + assert_eq!(hex.take(), None); + f.frame(vec![Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], &draw); + assert_eq!(hex.take().as_deref(), Some("00FF00"), "the press commits"); + // Three digits that spell the color the field holds commit nothing. + let same = |ui: &mut egui::Ui| { + let red = widgets::plain_field(ui, "red", 255.0, "", 0, 80.0); + hex.set(widgets::hex_field(ui, "hex", "AABBCC")); + red + }; + let mut f = Field::new(); + f.frame(vec![], &same); + f.frame(vec![tab()], &same); + f.frame(vec![tab()], &same); + f.frame(paste("abc"), &same); + assert_eq!(hex.take(), None); + f.frame(vec![tab()], &same); + assert_eq!(hex.take(), None, "abc over AABBCC commits nothing"); +} + /// #1692: the Stroke panel's dash and gap fields ([`widgets::opt_field`]) are blank while the /// selected objects' dashes differ. Tab out of the dash after typing into it gives the gap the /// focus; the gap then shows the dash's length, which the committed dash repeats as its gap. Tab @@ -318,6 +418,181 @@ fn key(key: Key, modifiers: Modifiers) -> Event { Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } } +/// What [`preset_row`]'s fields committed or picked in a frame: the two numbers, the text and the +/// pick of the field with an Auto entry. +type PresetPicks = (Option, Option, Option, Option); + +const NO_PICK: PresetPicks = (None, None, None, None); + +thread_local! { + /// Whether the last [`preset_row`] frame left an Enter press for a dialog drawn after it. + static ENTER_LEFT: Cell = const { Cell::new(false) }; +} + +/// One frame in `ctx` with `events` of a row of two numeric fields with a spinner and presets +/// (Stroke Weight, and a font size that is none of its presets), a text field with presets (Export +/// for Screens' scale) and a numeric field whose list starts with Auto, which is on (Leading). +fn preset_row(ctx: &egui::Context, time: &Cell, events: Vec) -> PresetPicks { + time.set(time.get() + 0.05); + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 300.0))); + let mut got = NO_PICK; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time.get()), screen_rect, focused: true, ..Default::default() }, |ui| { + ui.horizontal(|ui| { + got.0 = widgets::spin_field(ui, "weight", Some(2.0), Unit::Points, 100.0, 1.0, 0.0, &[1.0, 2.0, 4.0]); + got.1 = widgets::spin_field(ui, "size", Some(13.0), Unit::Points, 100.0, 1.0, 0.0, &[9.0, 12.0]); + got.2 = widgets::text_presets(ui, "scale", "1x", &["1x", "2x"], 100.0); + got.3 = widgets::spin_field_auto(ui, "leading", Some(14.0), Some(true), Unit::Points, 100.0, 1.0, 0.0, &[12.0, 14.0]); + }); + ENTER_LEFT.set(crate::shortcuts::pressed_anew(ui.ctx(), Key::Enter)); + }); + out.textures_delta.clear(); + got +} + +/// Tab goes from one field to the next in one press: it does not stop on a field's spinner or on +/// the chevron that opens its presets, and Shift+Tab goes back the same way. +#[test] +fn tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + preset_row(&ctx, &time, vec![]); + preset_row(&ctx, &time, vec![]); + let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 8, |events| { + preset_row(&ctx, &time, events); + }); + let widths: Vec = stops.iter().filter_map(|id| ctx.read_response(*id)).map(|r| r.rect.width()).collect(); + assert_eq!(widths.len(), 8, "{stops:?}"); + assert!(widths.iter().all(|w| *w > 40.0), "only the fields are Tab stops, not the spinners or chevrons: {widths:?}"); + assert_eq!(stops[..4], stops[4..], "four fields, then back to the first: {widths:?}"); + let mut back = vec![]; + for _ in 0..2 { + preset_row(&ctx, &time, vec![key(Key::Tab, Modifiers::SHIFT)]); + preset_row(&ctx, &time, vec![]); + back.extend(ctx.memory(|m| m.focused())); + } + assert_eq!(back, [stops[2], stops[1]], "Shift+Tab goes back field by field"); +} + +/// The Transparency row's opacity slider chevron is not a Tab stop either: Tab goes from the +/// Opacity field straight to the next field. +#[test] +fn tab_goes_from_the_opacity_field_past_its_slider_chevron() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + let frame = |events: Vec| { + time.set(time.get() + 0.05); + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 300.0))); + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time.get()), screen_rect, focused: true, ..Default::default() }, |ui| { + ui.horizontal(|ui| { + widgets::opacity_blend(ui, "transparency", Some(0.5), None, true); + widgets::spin_field(ui, "weight", Some(2.0), Unit::Points, 100.0, 1.0, 0.0, &[]); + }); + }); + out.textures_delta.clear(); + }; + frame(vec![]); + frame(vec![]); + let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 6, frame); + let widths: Vec = stops.iter().filter_map(|id| ctx.read_response(*id)).map(|r| r.rect.width()).collect(); + assert_eq!(widths.len(), 6, "{stops:?}"); + assert!(widths.iter().all(|w| *w > 20.0), "the 18 pt slider chevron is not a Tab stop: {widths:?}"); + assert_eq!(stops[..3], stops[3..], "blend mode, Opacity and the next field, then back: {widths:?}"); +} + +/// Gives the keyboard focus to the field that `tabs` presses of Tab reach in [`preset_row`], with +/// every list closed, and returns that field. +fn focus_preset_field(ctx: &egui::Context, time: &Cell, tabs: usize) -> Option { + egui::Popup::close_all(ctx); + ctx.memory_mut(|m| m.surrender_focus(m.focused().unwrap_or(egui::Id::NULL))); + preset_row(ctx, time, vec![]); + for _ in 0..tabs { + preset_row(ctx, time, vec![key(Key::Tab, Modifiers::NONE)]); + } + preset_row(ctx, time, vec![]); + ctx.memory(|m| m.focused()) +} + +/// The presets stay in reach of the keyboard: Alt+↓ in a field opens its preset list with the +/// keyboard focus on the chosen preset (the first entry when none is chosen, Auto when it is on), +/// Tab goes on to the next one and Enter picks it, without reaching a dialog's OK. The field then +/// has the keyboard focus again. +#[test] +fn alt_down_opens_a_fields_presets_for_the_keyboard() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + let alt_down = || key(Key::ArrowDown, Modifiers::ALT); + // (Tabs to the field, the pick after Tab and Enter in its list.) + let cases = [ + (1, (Some(4.0), None, None, None)), + (2, (None, Some(12.0), None, None)), + (3, (None, None, Some("2x".to_string()), None)), + (4, (None, None, None, Some(widgets::SpinPick::Value(12.0)))), + ]; + for (tabs, picked) in cases { + let field = focus_preset_field(&ctx, &time, tabs); + assert!(field.is_some(), "{tabs}"); + assert_eq!(preset_row(&ctx, &time, vec![alt_down()]), NO_PICK, "{tabs}"); + preset_row(&ctx, &time, vec![]); + let entry = ctx.memory(|m| m.focused()); + assert!(entry.is_some() && entry != field, "{tabs}: the list takes the focus"); + let layer = ctx.read_response(entry.unwrap_or(egui::Id::NULL)).map(|r| r.layer_id.order); + assert_eq!(layer, Some(egui::Order::Foreground), "{tabs}: on an entry of the list"); + preset_row(&ctx, &time, vec![key(Key::Tab, Modifiers::NONE)]); + preset_row(&ctx, &time, vec![]); + let mut got = preset_row(&ctx, &time, vec![enter()]); + assert!(!ENTER_LEFT.get(), "{tabs}: the list takes the Enter, so a dialog's OK does not"); + if got == NO_PICK { + got = preset_row(&ctx, &time, vec![]); + } + assert_eq!(got, picked, "{tabs}: Enter picks the next preset"); + preset_row(&ctx, &time, vec![]); + preset_row(&ctx, &time, vec![]); + assert!(!egui::Popup::is_any_open(&ctx), "{tabs}: the list closed"); + assert_eq!(ctx.memory(|m| m.focused()), field, "{tabs}: the field has the keyboard focus again"); + } +} + +/// Escape closes a preset list that Alt+↓ opened without picking anything, and the field has the +/// keyboard focus again. +#[test] +fn escape_closes_a_fields_presets_and_returns_to_the_field() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + for tabs in [1, 3] { + let field = focus_preset_field(&ctx, &time, tabs); + preset_row(&ctx, &time, vec![key(Key::ArrowDown, Modifiers::ALT)]); + preset_row(&ctx, &time, vec![]); + assert!(egui::Popup::is_any_open(&ctx), "{tabs}: Alt+↓ opened the list"); + let mut picks = vec![preset_row(&ctx, &time, vec![key(Key::Escape, Modifiers::NONE)])]; + picks.push(preset_row(&ctx, &time, vec![])); + picks.push(preset_row(&ctx, &time, vec![])); + assert!(picks.iter().all(|p| *p == NO_PICK), "{tabs}: nothing picked: {picks:?}"); + assert!(!egui::Popup::is_any_open(&ctx), "{tabs}: the list closed"); + assert_eq!(ctx.memory(|m| m.focused()), field, "{tabs}: the field has the keyboard focus again"); + } +} + +/// Only Alt+↓ with no other modifier opens the presets: Shift+Alt+↓ in a field with presets +/// steps it down by ten, as before, and leaves the list closed. +#[test] +fn shift_alt_down_still_steps_a_field_with_presets() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + let field = focus_preset_field(&ctx, &time, 2); + let mut got = preset_row(&ctx, &time, vec![key(Key::ArrowDown, Modifiers::SHIFT | Modifiers::ALT)]); + if got == NO_PICK { + got = preset_row(&ctx, &time, vec![]); + } + assert_eq!(got.1, Some(3.0), "13 pt steps down by ten"); + assert!(!egui::Popup::is_any_open(&ctx), "the list stays closed"); + assert_eq!(ctx.memory(|m| m.focused()), field); +} + #[test] fn math_before_the_unit_suffix_evaluates_in_every_unit() { assert_eq!(Unit::Pixels.parse("1080/2 px"), Some(540.0)); diff --git a/crates/ui-egui/src/tests/pathtype.rs b/crates/ui-egui/src/tests/pathtype.rs index 46f5eff67..4a14c1e0f 100644 --- a/crates/ui-egui/src/tests/pathtype.rs +++ b/crates/ui-egui/src/tests/pathtype.rs @@ -30,13 +30,18 @@ fn open_options(app: &mut VectorcraftApp) { } /// Two headless canvas frames → the vertices of the second one's tessellated mesh, which the GPU -/// takes in one buffer. +/// takes in one buffer, without the art's raster: the render worker's frame can land in either +/// frame of a comparison. fn canvas_vertices(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { let raw = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }; ctx.run_ui(raw(), |ui| crate::canvas::show(app, ui)).textures_delta.clear(); let mut out = ctx.run_ui(raw(), |ui| crate::canvas::show(app, ui)); out.textures_delta.clear(); - let mesh = |p: &egui::ClippedPrimitive| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { m.vertices.len() } else { 0 }; + let art = app.canvas.texture.as_ref().map(|t| t.id()); + let mesh = |p: &egui::ClippedPrimitive| match &p.primitive { + egui::epaint::Primitive::Mesh(m) if Some(m.texture_id) != art => m.vertices.len(), + _ => 0, + }; ctx.tessellate(out.shapes, out.pixels_per_point).iter().map(mesh).sum() } diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index 65e7f5762..fdd0798a8 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -405,6 +405,38 @@ fn keyboard_shortcuts_import_and_export_ask_off_the_ui_thread() { assert_eq!(*written.borrow(), ["/keys/out.json", "/keys/command.json"]); } +/// The Keyboard Shortcuts dialog's Export Text… asks off the UI thread for a `.txt` file and writes +/// the dialog's working set, as edited so far, as text where the dialog answers. +#[test] +fn keyboard_shortcuts_export_text_writes_the_working_set_as_text() { + let (mut app, shown, _) = app(); + let texts: Rc>> = Default::default(); + let t = texts.clone(); + app.services.write = Some(Box::new(move |path: &str, b: &[u8]| { + t.borrow_mut().push((path.to_string(), String::from_utf8_lossy(b).into_owned())); + Ok(()) + })); + let ov = crate::keymap::overrides_from(&[("view.grid", "Ctrl+Alt+G")]); + app.run("edit.keyboardShortcuts", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().expect("Keyboard Shortcuts"); + d.fields.insert("overrides".into(), crate::keymap::stored::to_value(&ov)); + d.fields.insert("set".into(), json!("Custom")); + click(&mut app, "Export Text…", dialogs); + match shown.requests().as_slice() { + [PickRequest::Save(pick)] => assert_eq!(pick.filters, FilePick::filters([("Text", &["txt"][..])])), + other => panic!("{other:?}"), + } + shown.answer(&["/keys/list.txt"]); + poll(&mut app); + let texts = texts.borrow(); + let [(path, text)] = texts.as_slice() else { panic!("{texts:?}") }; + assert_eq!(path, "/keys/list.txt"); + assert_eq!(*text, crate::shortcut_editor::export_text("Custom", &ov)); + assert!(text.contains(&format!("\t{}\n", crate::menus::pretty_shortcut("Ctrl+Alt+G"))), "{text}"); + assert!(app.ui.shortcut_overrides.is_empty(), "the working set is kept only on OK"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("shortcuts"), "the dialog stays open"); +} + /// Keyboard Shortcuts' Import… opens a shortcut set another app saved as text (`.kys`) in Import /// Shortcut Set over the dialog when its file dialog answers, and the dialog's working set stays in /// Keyboard Shortcuts, which Cancel shows again. @@ -431,6 +463,29 @@ fn keyboard_shortcuts_import_opens_a_text_set_over_the_dialog() { let _ = std::fs::remove_dir_all(dir); } +/// `shortcuts.export` and `shortcuts.import` given a `path` that is not a string open no file +/// dialog, read and write nothing, and give an error naming `path`; a null `path` still asks. +#[test] +fn keyboard_shortcuts_commands_reject_a_path_that_is_not_a_string() { + let (mut app, shown, written) = app(); + let read: Written = Default::default(); + let r = read.clone(); + app.services.read = Some(Box::new(move |path: &str| { + r.borrow_mut().push(path.to_string()); + Ok(SVG.to_vec()) + })); + for path in [json!(5), json!(true), json!({"file": "/keys/a.json"}), json!(["/keys/a.json"])] { + for id in ["shortcuts.export", "shortcuts.import"] { + assert_eq!(app.run(id, json!({ "path": path })), Err("`path` must be a string".to_string()), "{id} {path}"); + assert!(shown.requests().is_empty(), "{id} {path}: {:?}", shown.requests()); + } + } + assert!(written.borrow().is_empty() && read.borrow().is_empty()); + assert!(app.ui.shortcut_overrides.is_empty()); + assert_eq!(app.run("shortcuts.export", json!({"path": null})), Err("cancelled".to_string())); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)])); +} + /// A dialog's folder button answered after the dialog closed fills nothing, and the status says /// why. #[test] diff --git a/crates/ui-egui/src/tests/plugin_runs.rs b/crates/ui-egui/src/tests/plugin_runs.rs index a9b299eda..194ec133d 100644 --- a/crates/ui-egui/src/tests/plugin_runs.rs +++ b/crates/ui-egui/src/tests/plugin_runs.rs @@ -173,6 +173,46 @@ fn continue_in_background_hides_the_progress_window() { assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string()); } +/// A heavy filter of plug-in API level 2 that reports its progress gets a bar in its progress window, +/// with the share it reported, clamped; Cancel makes its `vc_cancelled` return 1 and it stops, +/// changing nothing. +#[test] +fn a_filter_that_reports_progress_shows_a_bar_and_sees_cancel() { + let mut app = app(); + let id = "test.ui.heavy.progress"; + let manifest = + format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","budget":"heavy","api":2,"permissions":["doc.read","doc.write","progress"]}}"#); + let first = "(call $vc_progress (i64.const 5000) (i64.const 1000)) (call $vc_progress (i64.const 250) (i64.const 1000)) + (block $stop (loop $l (br_if $stop (call $vc_cancelled)) (br $l)))"; + let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::reporting(&manifest, first, r#"{"objects":[]}"#)).unwrap()); + app.run("plugin.install", json!({"dataBase64": data, "allowHeavy": true, "grant": ["doc.read", "doc.write", "progress"]})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + app.run("plugin.dialog", json!({"id": id})).unwrap(); + // Waits for the run's thread to report the share (a handshake: the thread never stops before + // it is cancelled, and a run that ended leaves the loop). + loop { + plugin_runs::poll(&mut app); + match app.plugin_runs.running.first() { + Some(r) if r.progress.counts() != (250, 1000) => std::thread::yield_now(), + _ => break, + } + } + assert_eq!(app.plugin_runs.running.first().map(|r| r.progress.counts()), Some((250, 1000)), "the second report, after the first was clamped"); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + progress(&mut app, &ctx, 0.0); + progress(&mut app, &ctx, 1.0); + let shown = progress(&mut app, &ctx, 1.1); + assert!(shown.contains("The plug-in is computing…") && shown.contains("25 %"), "{shown}"); + assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1); + while !app.plugin_runs.running.is_empty() { + plugin_runs::poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.doc().unwrap().doc, doc); + assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string()); +} + /// `CRAFT_UI_HEAVY_FILTER_WASM=` writes a heavy test filter to install in the app /// (`order-paths-test-filter.wasm`): it computes until it is cancelled or its budget runs out, so /// its progress window stays up for a picture. diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 3bd693afa..d5e10d8d1 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -119,3 +119,83 @@ fn plugin_effects_open_the_effect_dialog_with_their_schema() { app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx", "index": 0})).unwrap(); assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("mode"), Some(&Value::from("b"))); } + +/// 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 { + 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() +} + +/// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash +/// and each permission in plain words, with hidden characters from the manifest escaped. Cancel +/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and +/// one that asks for another does. +#[test] +fn installing_a_plugin_that_lists_permissions_asks_first() { + let mut app = app(); + let id = "test.ui.permissions"; + // U+202E would show the text after it reversed, and U+2066 isolates it; a manifest's name may + // hold them, though not control characters, which its version may. + let name = "Tidy\u{202E}fdp.exe\u{2066}"; + let bytes = asking(id, name, r#"["doc.read","doc.write"]"#); + let data = vectorcraft_format::base64_encode(&bytes); + let r = app.run("ui.installPlugin", json!({"dataBase64": data, "name": "tidy.wasm"})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.str("message"), "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?"); + let hash = vectorcraft_plugins::permissions::sha256_hex(&bytes); + assert_eq!(d.fields.get("__params").map(|p| p["hash"].clone()), Some(json!(hash)), "OK installs the module shown"); + let detail = d.str("detail"); + assert_eq!( + detail, + format!( + "ID: {id}\nVersion: 3.0\\u{{202E}}\\u{{0009}}\\u{{0007}}\nFile SHA-256: {hash}\n\n• Read the objects it is given\n• Change, add and delete objects in the document" + ) + ); + assert!(!detail.chars().chain(d.str("message").chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}'))); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "not before the user agrees"); + dialogs::cancel(&mut app); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "Cancel installs nothing"); + // File › Open asks too, and OK installs it with what it lists granted. + io::open_bytes(&mut app, "tidy.wasm", &bytes, None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND)); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(vectorcraft_plugins::registry::get(id).is_some()); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash)); + // Again with the same permissions: installed without asking. + let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + assert!(app.ui.dialog.is_none(), "the same permissions don't ask again"); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); + // One that asks for another permission asks again. + let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + assert!(app.ui.dialog.as_ref().unwrap().str("detail").ends_with("• Keep data of its own with the document")); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "Cancel keeps the one installed"); + // OK keeps the `replace` it was asked with: `false` leaves the installed one in place. + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more), "replace": false})).unwrap(); + let _ = dialogs::confirm(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "replace: false is kept"); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} + +/// A plug-in of API level 2 that lists `progress` names it in plain words when it asks. +#[test] +fn installing_a_plugin_that_reports_progress_says_so() { + let mut app = app(); + let id = "test.ui.permissions.progress"; + let manifest = format!(r#"{{"id":"{id}","name":"Count","kind":"filter","api":2,"permissions":["doc.read","progress"]}}"#); + let bytes = wat::parse_str(tpl::reporting(&manifest, "(call $vc_progress (i64.const 1) (i64.const 2))", r#"{"objects":[]}"#)).unwrap(); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let detail = app.ui.dialog.as_ref().unwrap().str("detail"); + assert!(detail.ends_with("• Read the objects it is given\n• Show its progress, see Cancel and write log lines"), "{detail}"); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.permissions.len()), Some(2)); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} diff --git a/crates/ui-egui/src/tests/save.rs b/crates/ui-egui/src/tests/save.rs index ec8790f9f..b5e534eec 100644 --- a/crates/ui-egui/src/tests/save.rs +++ b/crates/ui-egui/src/tests/save.rs @@ -243,6 +243,22 @@ fn a_document_reopens_at_its_saved_view() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn a_saved_ai_file_reopens_on_the_artboard_active_when_saved() { + let d = dir("ai-artboard"); + let path = d.join("a.ai").to_string_lossy().to_string(); + let mut app = app(); + app.run("file.new", json!({"width": 100, "height": 100, "artboards": 3})).unwrap(); + app.sync_views(); + app.view_mut().unwrap().artboard = 1; + app.run("file.saveAs", json!({"path": path})).unwrap(); + app.run("file.close", json!({})).unwrap(); + let r = io::open_path(&mut app, &path).unwrap(); + assert_eq!(r["restored"], json!(true), "{r}"); + assert_eq!(app.view().map(|v| v.artboard), Some(1), "{r}"); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn replacing_a_file_that_opening_left_things_out_of_asks_first() { let d = dir("lossy"); diff --git a/crates/ui-egui/src/tests/saveext.rs b/crates/ui-egui/src/tests/saveext.rs index 7a2fa718b..497ac0c74 100644 --- a/crates/ui-egui/src/tests/saveext.rs +++ b/crates/ui-egui/src/tests/saveext.rs @@ -88,6 +88,8 @@ fn save_panels_show_the_file_type_they_write() { assert_eq!(last_shown(&shown), FilePick::filters([("PostScript", &["ps"][..])]), "Print to a PostScript file"); app.run("shortcuts.export", json!({})).unwrap(); assert_eq!(last_shown(&shown), FilePick::filters([("Shortcut sets", &["json"][..])]), "Keyboard Shortcuts › Export…"); + app.run("shortcuts.exportText", json!({})).unwrap(); + assert_eq!(last_shown(&shown), FilePick::filters([("Text", &["txt"][..])]), "Keyboard Shortcuts › Export Text…"); let presets = [ ("pdf.preset.save", json!({"name": "Mine"}), "pdf.preset.export", "PDF presets", pdfcmds::PRESET_EXTS), ("print.presets.save", json!({"name": "Mine"}), "print.presets.export", "Print presets", printpresets::PRESET_EXTS), diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 53281dc69..55fb3fbc6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -566,16 +566,29 @@ pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String { /// The Japanese, Georgian and Devanagari craft-fonts faces (when built with `CRAFT_FONTS_DIR`) as fallbacks at /// the end of every family, after the app's own fonts and before the installed fonts `ui_fonts` /// adds: BIZ UDPGothic first (bold first in the semibold family), then the Mincho faces, then Noto -/// Sans Georgian. +/// Sans Georgian. Each face sits on the baseline of the first face of the family it is in (see +/// `ui_fonts::baseline_copies`). fn add_craft_fonts(fonts: &mut FontDefinitions) { + // The name of each face's copy in each family. + let mut copies: std::collections::HashMap<(FontFamily, String), String> = std::collections::HashMap::new(); for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese() || f.is_georgian() || f.scripts.contains(&"Deva")) { - fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes))); + let name = craft_font_name(f); + for (copy, data, families) in crate::ui_fonts::baseline_copies(fonts, &name, &FontData::from_static(f.bytes)) { + for family in families { + copies.insert((family, name.clone()), copy.clone()); + } + fonts.font_data.insert(copy, Arc::new(data)); + } } for (family, stack) in fonts.families.iter_mut() { let bold = *family == FontFamily::Name(FONT_UI_SEMIBOLD.into()); - stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(craft_font_name)); - stack.extend(vectorcraft_text::craft_fonts::georgian_ui_fonts().into_iter().map(craft_font_name)); - stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Deva")).map(craft_font_name)); + let copy = |f: &vectorcraft_text::CraftFont| { + let name = craft_font_name(f); + copies.get(&(family.clone(), name.clone())).cloned().unwrap_or(name) + }; + stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(copy)); + stack.extend(vectorcraft_text::craft_fonts::georgian_ui_fonts().into_iter().map(copy)); + stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Deva")).map(copy)); } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 2280853e1..be04485d0 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -873,9 +873,32 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { // long press) leaves it to that button. app.ui.flyout = None; } + if app.ui.flyout.is_some() { + mark_flyout_open(ctx); + } let _ = theme::semibold; } +/// The pass in which a tool group's flyout or the All Tools drawer last stayed open. +fn flyout_open_pass_id() -> egui::Id { + egui::Id::unique("flyout-open-pass") +} + +/// Records that a tool group's flyout or the All Tools drawer drew in this pass and stays open. +pub(crate) fn mark_flyout_open(ctx: &egui::Context) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(flyout_open_pass_id(), pass)); +} + +/// Whether a tool group's flyout or the All Tools drawer is on screen in the main window this pass. +/// One left set while its toolbar is hidden does not draw, so it does not count. The toolbars draw +/// before the panels, which read this. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn flyout_open(ctx: &egui::Context) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data(|d| d.get_temp::(flyout_open_pass_id())) == Some(pass) +} + /// The toolbar made with New Toolbar whose group the open flyout shows (none: the Tools panel's). pub(crate) fn flyout_toolbar_id() -> egui::Id { egui::Id::unique("flyout-toolbar") diff --git a/crates/ui-egui/src/ui_fonts.rs b/crates/ui-egui/src/ui_fonts.rs index bcb6c909e..ac3f260e9 100644 --- a/crates/ui-egui/src/ui_fonts.rs +++ b/crates/ui-egui/src/ui_fonts.rs @@ -4,16 +4,17 @@ //! which `theme::install_fonts` adds after the UI fonts, so Japanese never gets here; nothing else //! is bundled for them: the first time a frame paints a character the UI fonts //! don't have, an installed font covering it is looked for (in the background on native) and added -//! to egui's families as their last fallback, from the next frame on. Text the UI fonts cover costs -//! no font loading or memory. On the web, which has no system fonts, such characters stay -//! missing-glyph boxes (Japanese too, unless built with craft-fonts). +//! to egui's families as their last fallback, on the baseline of each family's first face, from +//! the next frame on. Text the UI fonts cover costs no font loading or memory. On the web, which +//! has no system fonts, such characters stay missing-glyph boxes (Japanese too, unless built with +//! craft-fonts). use std::collections::HashSet; use std::sync::Arc; use egui::epaint::Shape; use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily}; -use egui::{FontData, FontFamily, FontId, LayerId}; +use egui::{FontData, FontDefinitions, FontFamily, FontId, FontTweak, LayerId}; use vectorcraft_text::{FontDb, FontFace}; #[derive(Default)] @@ -109,9 +110,9 @@ impl UiFonts { missing } - /// Add `faces` to every egui family, after the fonts it has. + /// Add `faces` to every egui family, after the fonts it has, on the baseline of the family's + /// first face. fn install(&mut self, ctx: &egui::Context, faces: &[Arc]) { - let families: Vec = ctx.fonts(|f| f.definitions().families.keys().cloned().collect()); for face in faces { let name = format!("{} {} #{}", face.family, face.style, face.face_index()); if !self.added.insert(name.clone()) { @@ -121,8 +122,10 @@ impl UiFonts { // face it loads for the session) instead of copied: one handle is leaked per font added. let face: &'static FontFace = Box::leak(Box::new(face.clone())); let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) }; - let families = families.iter().map(|family| InsertFontFamily { family: family.clone(), priority: FontPriority::Lowest }).collect(); - ctx.add_font(FontInsert { name, data, families }); + for (name, data, families) in ctx.fonts(|f| baseline_copies(f.definitions(), &name, &data)) { + let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect(); + ctx.add_font(FontInsert { name, data, families }); + } } if !faces.is_empty() { ctx.request_repaint(); @@ -138,6 +141,53 @@ impl UiFonts { } } +/// The copies of the fallback face `data` that `defs`' families take, one for each baseline they +/// need: each copy is `data` with the `y_offset_factor` that puts its glyphs on the baseline of the +/// first face of the families listed with it. Families whose first faces need the same shift share +/// a copy. The first copy (the Proportional family's, when `defs` has it) is named `name`; each +/// other copy adds the name of its families' first face. +pub(crate) fn baseline_copies(defs: &FontDefinitions, name: &str, data: &FontData) -> Vec<(String, FontData, Vec)> { + let mut copies: Vec<(String, FontData, Vec)> = vec![]; + for (family, stack) in &defs.families { + let head = stack.first().and_then(|head| Some((head.as_str(), defs.font_data.get(head)?))); + let shift = head.and_then(|(_, data)| row_metrics(data)).map_or(0.0, |row| baseline_shift(row, (*data.font).as_ref(), data.index)); + if let Some((_, _, families)) = copies.iter_mut().find(|(_, copy, _)| copy.tweak.y_offset_factor.to_bits() == shift.to_bits()) { + families.push(family.clone()); + continue; + } + let copy_name = if copies.is_empty() { name.to_owned() } else { format!("{name} / {}", head.map_or("", |(head, _)| head)) }; + let tweak = FontTweak { y_offset_factor: shift, ..data.tweak.clone() }; + copies.push((copy_name, FontData { tweak, ..data.clone() }, vec![family.clone()])); + } + copies +} + +/// The ascent, row height and vertical offset of a family's first face `data`, in ems of the font +/// size, with that face's `FontTweak` scale applied, as egui lays out its rows. `None` when the +/// face can't be read. +fn row_metrics(data: &FontData) -> Option<(f32, f32, f32)> { + let (ascent, height) = em_metrics((*data.font).as_ref(), data.index)?; + let scale = data.tweak.scale; + Some((ascent * scale, height * scale, data.tweak.y_offset_factor * scale)) +} + +/// The ascent and row height (ascent, descent and line gap) of the face at `index` of `data`, in ems. +fn em_metrics(data: &[u8], index: u32) -> Option<(f32, f32)> { + let (upem, ascent, descent, gap) = vectorcraft_text::line_metrics(data, index)?; + Some((ascent / upem, (ascent - descent + gap) / upem)) +} + +/// The `y_offset_factor` that puts the glyphs of the face at `index` of `face` on the baseline of +/// the row's first face, whose ascent, row height and offset in ems are `row`. egui draws a +/// fallback glyph's baseline at its face's ascent plus half the difference between the two row +/// heights, so a face with a taller ascent or descent sits off the baseline by the shift computed +/// here, in ems of the font size, limited to half an em either way. +fn baseline_shift(row: (f32, f32, f32), face: &[u8], index: u32) -> f32 { + let (row_ascent, row_height, row_offset) = row; + let shift = em_metrics(face, index).map_or(0.0, |(ascent, height)| row_ascent + row_offset - (ascent + 0.5 * (row_height - height))); + if shift.is_finite() { shift.clamp(-0.5, 0.5) } else { 0.0 } +} + /// Call `f` with the text of `shape` and the shapes it holds. fn painted_text(shape: &Shape, f: &mut impl FnMut(&str)) { match shape { @@ -242,6 +292,94 @@ mod tests { assert!(has_glyph(&ctx, 'L')); } + /// An installed face with a taller ascent and descent than the UI faces (a CJK system face's + /// proportions) draws on the baseline of each family's first face, the monospace families' + /// included: `H` and the test face's 编 (the same `H` outline, which stands on the baseline) end + /// at the same height. + #[test] + fn fallback_faces_sit_on_the_ui_baseline() { + use vectorcraft_text::test_fonts::{TALL_IDEOGRAPH_FAMILY, tall_ideograph_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(tall_ideograph_font().expect("the test face builds")); + let face = db.face(TALL_IDEOGRAPH_FAMILY, "Regular").expect("the test face loads"); + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + crate::theme::install_fonts(&ctx); + let mut fonts = UiFonts::default(); + frame(&ctx, &mut fonts, "H"); + assert!(!has_glyph(&ctx, '编'), "no UI face has 编"); + fonts.install(&ctx, &[face]); + frame(&ctx, &mut fonts, "H"); + assert!(has_glyph(&ctx, '编'), "the test face draws 编"); + let bottom = |g: &egui::epaint::text::Glyph| g.pos.y + g.uv_rect.offset.y + g.uv_rect.size.y; + let families: Vec = ctx.fonts(|f| f.definitions().families.keys().cloned().collect()); + for family in families { + for size in [13.0_f32, 24.0, 40.0] { + let galley = ctx.fonts_mut(|f| f.layout_no_wrap("H编".into(), FontId::new(size, family.clone()), egui::Color32::WHITE)); + let row = &galley.rows[0].glyphs; + assert_eq!(row.len(), 2, "H编 lays out as two glyphs"); + // Half a point: one device pixel at 2 pixels per point, for each glyph's rounding. + let drift = bottom(&row[1]) - bottom(&row[0]); + assert!(drift.abs() <= 0.5, "{family:?} at {size} pt: 编 ends {drift:.2} pt below H (negative: above)"); + } + } + } + + /// The bundled craft-fonts faces draw on the baseline of each family's first face: a glyph of + /// each ends as far below `H`'s baseline as it does below its own face's baseline when that face + /// heads a family. + #[test] + fn craft_fonts_sit_on_the_baseline_of_each_family() { + use vectorcraft_text::CraftFont; + use vectorcraft_text::craft_fonts::{georgian_ui_fonts, japanese_ui_fonts}; + // The face each family draws a script in, with a character of it: (regular face, semibold + // family's face, character). + let first = |faces: Vec<&'static CraftFont>| faces.first().copied(); + let deva = vectorcraft_text::CRAFT_FONTS.iter().find(|f| f.scripts.contains(&"Deva")); + let probes: Vec<(&CraftFont, &CraftFont, char)> = [ + first(japanese_ui_fonts(false)).zip(first(japanese_ui_fonts(true))).map(|(regular, bold)| (regular, bold, '編')), + first(georgian_ui_fonts()).map(|f| (f, f, 'ა')), + deva.map(|f| (f, f, 'क')), + ] + .into_iter() + .flatten() + .collect(); + if probes.is_empty() { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let mut defs = crate::theme::interface_fonts(); + let families: Vec = defs.families.keys().cloned().collect(); + let probe_name = |f: &CraftFont| format!("probe {}", crate::theme::craft_font_name(f)); + let probe = |f: &CraftFont| FontFamily::Name(probe_name(f).into()); + for &(regular, bold, _) in &probes { + for f in [regular, bold] { + defs.font_data.insert(probe_name(f), Arc::new(FontData::from_static(f.bytes))); + defs.families.insert(probe(f), vec![probe_name(f)]); + } + } + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + ctx.set_fonts(defs); + frame(&ctx, &mut UiFonts::default(), "H"); + let bottom = |g: &egui::epaint::text::Glyph| g.pos.y + g.uv_rect.offset.y + g.uv_rect.size.y; + let glyphs = |text: &str, font: FontId| ctx.fonts_mut(|f| f.layout_no_wrap(text.into(), font, egui::Color32::WHITE)).rows[0].glyphs.clone(); + for family in families { + let semibold = family == FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into()); + for &(regular, bold, c) in &probes { + let face = if semibold { bold } else { regular }; + for size in [13.0_f32, 24.0, 40.0] { + let own = glyphs(&c.to_string(), FontId::new(size, probe(face))); + let row = glyphs(&format!("H{c}"), FontId::new(size, family.clone())); + assert_eq!((own.len(), row.len()), (1, 2), "{c} lays out as one glyph"); + // Half a point: one device pixel at 2 pixels per point, for each glyph's rounding. + let drift = (bottom(&row[1]) - row[0].pos.y) - (bottom(&own[0]) - own[0].pos.y); + assert!(drift.abs() <= 0.5, "{family:?} at {size} pt: {c} ({}) ends {drift:.2} pt below its baseline", face.family); + } + } + } + } + #[test] fn cjk_names_get_an_installed_fallback_font() { let text = "Logo 标志 ロゴ 로고"; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5a6daf1fa..e4319809b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -23,6 +23,7 @@ mod toggles; pub use blend::{ TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point, + reference_point_named, }; pub use buttons::{ SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button, @@ -32,7 +33,7 @@ pub(crate) use buttons::{button_width, icon_button_info, primary_button_width, s #[cfg(test)] use chips::gradient_chip_colors; pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile}; -pub(crate) use color::pointer_phase; +pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase}; pub use color::{ Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider, color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, segment_color, @@ -43,6 +44,8 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; +#[cfg(test)] +pub(crate) use dropdowns::menu_floor; pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, @@ -68,7 +71,7 @@ pub use text::{ exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets, text_presets_named, }; -pub use toggles::{check, check_box, check_name, check_tip, check3, radio, radio_button}; +pub use toggles::{check, check_box, check_name, check_tip, check3, check3_tip, radio, radio_button}; /// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also /// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do @@ -237,6 +240,121 @@ pub(crate) mod accessibility_tests { let chosen = if i == 4 { Toggled::True } else { Toggled::False }; assert_eq!(role_and_state(&tree, label), (Role::RadioButton, Some(chosen)), "{label}"); } + let grid = named(&tree, "Reference Point"); + assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some("Center")), "the grid is a radio group showing the current point"); + } + + /// Screen readers find the nine squares inside the Reference Point radio group. + #[test] + fn the_reference_point_group_holds_its_squares() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + let mut nodes = vec![]; + for time in [0.0, 0.1] { + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + reference_point(ui, 0); + }); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default(); + } + let node = |id: &egui::accesskit::NodeId| nodes.iter().find(|(n, _)| n == id).map(|(_, n)| n); + let Some((group, _)) = nodes.iter().find(|(_, n)| n.label() == Some("Reference Point")) else { panic!("no Reference Point group") }; + let mut inside = vec![]; + let mut todo = vec![*group]; + while let Some(id) = todo.pop() { + for child in node(&id).map(|n| n.children()).unwrap_or_default() { + inside.extend(node(child).and_then(|n| n.label()).map(str::to_owned)); + todo.push(*child); + } + } + for label in ["Top Left", "Top", "Top Right", "Left", "Center", "Right", "Bottom Left", "Bottom", "Bottom Right"] { + assert!(inside.iter().any(|l| l == label), "{label} is in the group: {inside:?}"); + } + } + + /// A frame of a button, the reference point grid at `point` and another button, after `events`; + /// a new point from the grid replaces `point`. Returns the two buttons' ids. + fn grid_frame(ctx: &egui::Context, events: Vec, point: &mut usize) -> (egui::Id, egui::Id) { + let mut ids = (egui::Id::NULL, egui::Id::NULL); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + ids.0 = ui.button("Before").id; + if let Some(i) = reference_point(ui, *point) { + *point = i; + } + ids.1 = ui.button("After").id; + }); + // No renderer takes the font atlas updates in a test; dropping them unapplied panics. + out.textures_delta.clear(); + ids + } + + /// Tab stops once on the reference point grid, not on each of its nine squares. + #[test] + fn the_reference_point_grid_is_one_tab_stop() { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut point = 4; + let mut ids = (egui::Id::NULL, egui::Id::NULL); + let stops = tab_stops(&ctx, 3, |events| ids = grid_frame(&ctx, events, &mut point)); + assert_eq!(stops.len(), 3, "{stops:?}"); + assert_eq!(stops[0], ids.0, "the first Tab reaches the button before the grid"); + let size = ctx.read_response(stops[1]).map(|r| r.rect.size()); + assert_eq!(size, Some(egui::Vec2::splat(23.0)), "the second Tab reaches the whole grid, not one of its squares"); + assert_eq!(stops[2], ids.1, "the third Tab leaves the grid for the button after it"); + assert_eq!(point, 4, "Tab leaves the point as it was"); + } + + /// While the grid has the focus, the arrow keys move the point one square, stop at the edges and + /// keep the focus on the grid. + #[test] + fn arrow_keys_move_the_reference_point() { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut point = 4; + let stops = tab_stops(&ctx, 2, |events| { + grid_frame(&ctx, events, &mut point); + }); + let grid = stops[1]; + let arrow = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + for (key, want) in [ + (egui::Key::ArrowRight, 5), + (egui::Key::ArrowUp, 2), + (egui::Key::ArrowRight, 2), + (egui::Key::ArrowUp, 2), + (egui::Key::ArrowLeft, 1), + (egui::Key::ArrowDown, 4), + (egui::Key::ArrowDown, 7), + (egui::Key::ArrowLeft, 6), + (egui::Key::ArrowDown, 6), + ] { + grid_frame(&ctx, vec![arrow(key)], &mut point); + grid_frame(&ctx, vec![], &mut point); + assert_eq!(point, want, "{key:?}"); + assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "{key:?} keeps the focus on the grid"); + } + // From no point, an arrow moves from the center. + point = 9; + grid_frame(&ctx, vec![arrow(egui::Key::ArrowLeft)], &mut point); + grid_frame(&ctx, vec![], &mut point); + assert_eq!(point, 3, "← from no point chose the middle of the left side"); + assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "← from no point keeps the focus on the grid"); + } + + /// A locator under another label takes that label, without its colon, as the group's name. + #[test] + fn a_named_reference_point_group_takes_its_label() { + // Each locator gets its own id scope, so their ids differ. + let tree = tree(|ui| { + for (point, label) in [(4, "Pivot:"), (0, "Origine :"), (8, "基準点:")] { + ui.push_id(label, |ui| reference_point_named(ui, point, label)); + } + }); + for (label, value) in [("Pivot", "Center"), ("Origine", "Top Left"), ("基準点", "Bottom Right")] { + let grid = named(&tree, label); + assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some(value)), "{label}"); + } } #[test] @@ -520,12 +638,43 @@ mod menu_room_tests { #[test] fn menu_stops_above_the_bottom_bars() { // Window 1000 pt tall, bars start at 948: a menu opening at y = 100 gets 836 pt, not 888. - assert_eq!(menu_room(948.0, 100.0), 836.0); + assert_eq!(menu_room(948.0, 100.0, 980.0), 836.0); } #[test] fn menu_keeps_a_minimum_height() { - assert_eq!(menu_room(300.0, 290.0), 120.0); + assert_eq!(menu_room(300.0, 290.0, 280.0), 120.0); + } + + /// A native panel window can be 100 pt tall, shorter than the minimum: the list then takes the + /// whole window, so its last items can scroll into view. + #[test] + fn a_window_shorter_than_the_minimum_limits_the_menu() { + assert_eq!(menu_room(100.0, 24.0, 92.0), 92.0); + } + + /// The floor one window records does not reach a menu in another window of the same context. + #[test] + fn the_menu_floor_applies_in_the_window_that_recorded_it() { + let ctx = egui::Context::default(); + let frame = |viewport_id: egui::ViewportId, height: f32, floor: Option| { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, height))); + let mut seen = 0.0; + let viewports = std::iter::once((viewport_id, egui::ViewportInfo::default())).collect(); + let mut full = ctx.run_ui(egui::RawInput { viewport_id, viewports, screen_rect, ..Default::default() }, |ui| { + if let Some(y) = floor { + set_menu_floor(ui.ctx(), y); + } + seen = dropdowns::menu_floor(ui.ctx()); + }); + full.textures_delta.clear(); + seen + }; + assert_eq!(frame(egui::ViewportId::ROOT, 1000.0, Some(948.0)), 948.0, "the main window stops at its bottom bars"); + let panel = egui::ViewportId::from_hash_of("panel window"); + assert_eq!(frame(panel, 240.0, None), 240.0, "a shorter panel window stops at its own bottom edge"); + assert_eq!(frame(panel, 1400.0, None), 1400.0, "and a taller one too"); + assert_eq!(frame(egui::ViewportId::ROOT, 1000.0, None), 948.0, "the main window keeps its floor"); } } diff --git a/crates/ui-egui/src/widgets/blend.rs b/crates/ui-egui/src/widgets/blend.rs index 5fdf065b1..6edfab334 100644 --- a/crates/ui-egui/src/widgets/blend.rs +++ b/crates/ui-egui/src/widgets/blend.rs @@ -71,7 +71,9 @@ pub fn opacity_blend( edit = Some(TransparencyEdit::Opacity(o, Live::Released)); } }); - let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); + // Like a field's preset chevron, the slider's chevron is not a Tab stop: the field takes + // the same value from the keyboard, and Tab goes on to the next field. + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::CLICK } else { Sense::hover() }); ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!("Opacity"))); @@ -92,14 +94,29 @@ pub fn opacity_blend( } /// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around -/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked. +/// the box (the centre one stands alone), the current one filled. Returns a new index when a square +/// is clicked or an arrow key moves the point. +/// +/// The grid is one Tab stop. While it has the keyboard focus it shows an outline, and ←, →, ↑ and +/// ↓ move the point one square, stopping at the edges; from no point (`current` 9 or more) they +/// move from the center. Screen readers get a radio group named Reference Point, with the current +/// point as its value, holding the nine squares as radio buttons. pub fn reference_point(ui: &mut Ui, current: usize) -> Option { + reference_point_named(ui, current, tl!("Reference Point")) +} + +/// [`reference_point`] for a locator that a label other than Reference Point names, such as +/// `Pivot:`: screen readers name the radio group after `label`, without its final colon. +pub fn reference_point_named(ui: &mut Ui, current: usize, label: &str) -> Option { + let name = label.trim_end().trim_end_matches([':', ':']).trim_end(); let t = Tokens::get(ui.ctx()); // 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through // the squares' centres stay crisp. let (sq, step) = (5.0, 9.0); let size = 2.0 * step + sq; let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + // The grid takes the focus; the squares take clicks only, so Tab does not stop on each one. + let grid = ui.interact(rect, ui.scope_id().with("refpt"), Sense::focusable_noninteractive()); let o = rect.min.round(); let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step); let line = Stroke::new(1.0, t.text_dim); @@ -117,10 +134,22 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { tl!("Bottom"), tl!("Bottom Right"), ]; + grid.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::Role::RadioGroup, ui.is_enabled(), name); + info.current_text_value = names.get(current).map(|n| n.to_string()); + info + }); + if grid.has_focus() && ui.is_enabled() { + out = reference_point_keys(ui, grid.id, current); + } + // The squares go in a child `Ui` whose screen-reader parent is the group, so that the group + // holds them. + let scope = ui.scope_id(); + let squares = ui.new_child(egui::UiBuilder::new().max_rect(rect).accessibility_parent(grid.id)); for (i, name) in names.into_iter().enumerate() { let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); - let resp = ui.interact(r.expand(2.0), ui.scope_id().with(("refpt", i)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), i == current, name)); + let resp = squares.interact(r.expand(2.0), scope.with(("refpt", i)), Sense::CLICK); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, squares.is_enabled(), i == current, name)); if resp.clicked() { out = Some(i); } @@ -131,9 +160,48 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside); } } + if grid.has_focus() { + ui.painter().rect_stroke(rect.expand(2.0), 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } out } +/// The point this frame's arrow keys move the focused reference point grid `id` to from +/// `current`, when it differs. +fn reference_point_keys(ui: &Ui, id: egui::Id, current: usize) -> Option { + use egui::{Event, Key}; + // The arrows move the point and do not move the focus. egui keeps the filter only when the grid + // also had the focus in the frame before, and applies it from the next frame; until then, an + // arrow the grid takes cancels the focus move that egui set at the start of the frame. + let filter = egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }; + ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); + let mut at = if current < 9 { current } else { 4 }; + let mut arrows = false; + ui.input_mut(|i| { + i.events.retain(|e| { + let Event::Key { key, pressed: true, modifiers, .. } = e else { return true }; + if !modifiers.is_none() { + return true; + } + let (col, row) = (at % 3, at / 3); + at = match key { + Key::ArrowLeft => row * 3 + col.saturating_sub(1), + Key::ArrowRight => row * 3 + (col + 1).min(2), + Key::ArrowUp => row.saturating_sub(1) * 3 + col, + Key::ArrowDown => (row + 1).min(2) * 3 + col, + _ => return true, + }; + arrows = true; + false + }); + }); + if !arrows { + return None; + } + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + (at != current).then_some(at) +} + /// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees, /// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole /// degrees (-179..180), Shift in 45° steps. Returns the new angle. diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index cb44bfe04..72f74f7b9 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -1,6 +1,6 @@ use super::labels::{label_field, name_field, take_previous_label}; use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple}; -use super::text::{after_shift_enter, select_all, shift_enter_keeps_focus}; +use super::text::{after_shift_enter, select_all, select_all_on_focus, shift_enter_keeps_focus}; use super::{Color, Color32, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, pos2, vec2}; /// Drag phase of a live slider/handle: previews while dragging, commits on release. @@ -432,48 +432,167 @@ pub fn hex_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str> let label = take_previous_label(ui); ui.label(egui::RichText::new("#").size(15.0).color(t.text)); let id = id_with(ui.scope_id(), id); + hex_text(ui, id, hex, |ui, buf, editing| { + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(5) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + // As tall as the other fields (26 points with the margin), whatever the font's line + // height: egui 0.37 gives a TextEdit the theme's interact height, so lower it here too. + ui.spacing_mut().interact_size.y = 18.0; + ui.set_height(18.0); + ui.add( + egui::TextEdit::singleline(buf) + .id(id) + .frame(egui::Frame::NONE) + .align(egui::Align2::LEFT_CENTER) + .desired_width(62.0) + .char_limit(HEX_FIELD_CHARS) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner; + name_field(label_field(resp, label), name) + }) +} + +/// The most characters a hex field's text box holds: six digits and a `#`. +pub(crate) const HEX_FIELD_CHARS: usize = 7; + +/// What happens to a paste into a hex field. +#[derive(Debug, PartialEq, Eq)] +enum HexPaste { + /// Hex digits that leave at most six digits with the text that stays: the text box inserts them. + Insert, + /// A full color code: its digits replace the field's, wherever the caret is. + Replace(String), + /// Anything else: the field stays as it was. + Ignore, +} + +/// What a paste of `text` does to a hex field in which `kept` characters stay, not counting a typed `#` +/// that stays. `whole` says the paste goes over all of the field's text. A code has three or six digits +/// with or without `#`, and spaces and line breaks around it are ignored. Bare digits that do not go over +/// the whole text are inserted when the result has at most six digits, except that three digits are +/// inserted only when exactly three digits stay. Bare digits are not inserted when `before_hash` says the +/// caret is before a `#` that stays. Any other code replaces the field's text, and any other paste is +/// ignored. +fn hex_paste(text: &str, kept: usize, whole: bool, before_hash: bool) -> HexPaste { + let code = text.trim(); + let digits = code.strip_prefix('#').unwrap_or(code); + if digits.is_empty() || digits.len() > 6 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) { + HexPaste::Ignore + } else if digits == text && !whole && !before_hash && kept + digits.len() <= 6 && (digits.len() != 3 || kept == 3) { + HexPaste::Insert + } else if matches!(digits.len(), 3 | 6) { + HexPaste::Replace(digits.to_string()) + } else { + HexPaste::Ignore + } +} + +/// Takes this frame's pastes into hex field `id` from the events before its text box reads them, and +/// applies each one that replaces `buf`, with the caret after the new digits. Returns whether one did. +fn take_hex_paste(ui: &Ui, id: egui::Id, buf: &mut String) -> bool { + if !ui.memory(|m| m.has_focus(id)) { + return false; + } + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let (start, selected) = state.cursor.char_range().map_or((0, 0), |r| { + let r = r.as_sorted_char_range(); + (r.start.0, r.end.0 - r.start.0) + }); + let replaced = ui.input_mut(|i| take_hex_pastes(&mut i.events, buf, start, selected)); + if replaced { + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(buf.len())))); + state.store(ui.ctx(), id); + } + replaced +} + +/// Removes the pastes from `events` that [`hex_paste`] does not leave to the text box, in order, and +/// applies each one that replaces `buf`. The caret is at character `start` with `selected` characters +/// selected. A replacement also drops the bare-digit pastes kept from earlier in the frame, which the text +/// box would otherwise insert into the new digits. Returns whether a paste replaced `buf`. +fn take_hex_pastes(events: &mut Vec, buf: &mut String, start: usize, mut selected: usize) -> bool { + let mut replaced = false; + let mut kept_events = Vec::with_capacity(events.len()); + for e in events.drain(..) { + let egui::Event::Paste(text) = &e else { + kept_events.push(e); + continue; + }; + let len = buf.chars().count(); + let hash = buf.starts_with('#') && start > 0; + let before_hash = buf.starts_with('#') && start == 0 && selected == 0; + match hex_paste(text, len.saturating_sub(selected + usize::from(hash)), selected > 0 && selected >= len, before_hash) { + HexPaste::Insert => kept_events.push(e), + HexPaste::Replace(digits) => { + *buf = digits; + selected = 0; + replaced = true; + kept_events.retain(|k| !matches!(k, egui::Event::Paste(_))); + } + HexPaste::Ignore => {} + } + } + *events = kept_events; + replaced +} + +/// `digits` with a three-digit code written out as six by doubling each digit. Other text is unchanged. +fn full_hex(digits: &str) -> String { + if digits.len() == 3 && digits.bytes().all(|b| b.is_ascii_hexdigit()) { + digits.chars().flat_map(|c| [c, c]).collect() + } else { + digits.to_string() + } +} + +/// The text and commit rules of a hex field `id` showing `hex`; `draw` draws its text box with the +/// text and whether the field has the focus. A click or keyboard focus selects the digits, a pasted +/// code replaces them ([`hex_paste`]), and Enter, Shift+Enter (which keeps the focus outside dialogs), +/// a press elsewhere and focus loss commit them. Returns the digits, without `#` and with a three-digit +/// code written out as six, on commit when they were typed into the field and differ from `hex`. +pub(crate) fn hex_text(ui: &mut Ui, id: egui::Id, hex: &str, draw: impl FnOnce(&mut Ui, &mut String, bool) -> Response) -> Option { let editing = ui.memory(|m| m.has_focus(id)); + // A press outside the field ends its editing in this frame, so the text typed is committed + // before the press acts, as in the numeric fields. + if editing + && let Some(at) = ui.input(|i| i.pointer.primary_pressed().then(|| i.pointer.interact_pos()).flatten()) + && ui.ctx().read_response(id).is_some_and(|r| !r.rect.contains(at)) + { + ui.memory_mut(|m| m.surrender_focus(id)); + } // Until the user types into it, the field shows `hex` as it is now, also while it has the // focus. Tab can give it the focus in the frame another field commits an edit, before the edit // changes `hex`; the text from before that edit is not committed when the focus moves on - // (#1692). + // (#1692). A field drawn before this one can take the focus on a press, so `editing` is already + // false in the frame the field reports `lost_focus`: the text typed is still taken then. Escape + // drops it. let edited_id = id.with("edited"); - let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); + let had_focus = ui.memory(|m| m.had_focus_last_frame(id)) && !ui.input(|i| i.key_pressed(egui::Key::Escape)); + let edited = (editing || had_focus) && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); let mut buf: String = if edited { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; if after_shift_enter(ui, id) { select_all(ui, id, &buf); } let kept = shift_enter_keeps_focus(ui, id); - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(5) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - // As tall as the other fields (26 points with the margin), whatever the font's line - // height: egui 0.37 gives a TextEdit the theme's interact height, so lower it here too. - ui.spacing_mut().interact_size.y = 18.0; - ui.set_height(18.0); - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .align(egui::Align2::LEFT_CENTER) - .desired_width(62.0) - .char_limit(7) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) - }) - .inner; - let resp = name_field(label_field(resp, label), name); - let commit = (resp.lost_focus() || kept) && buf != hex; + let pasted = take_hex_paste(ui, id, &mut buf); + let resp = draw(ui, &mut buf, editing); + select_all_on_focus(ui, &resp, &buf); + let digits = full_hex(buf.strip_prefix('#').unwrap_or(&buf)); + let commit = (resp.lost_focus() || kept) && !digits.eq_ignore_ascii_case(hex); + let committed = commit.then_some(digits); let focused = ui.memory(|m| m.has_focus(id)); ui.data_mut(|d| { - d.insert_temp(id, buf.clone()); - d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); + d.insert_temp(edited_id, (edited || pasted || resp.changed()) && focused && !kept); + d.insert_temp(id, buf); }); - commit.then_some(buf) + committed } /// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`. @@ -651,3 +770,55 @@ pub fn region_texture( let color = crate::canvas::color_image(&img); ctx.load_texture(name, color, egui::TextureOptions::LINEAR) } + +#[cfg(test)] +mod hex_paste_tests { + use super::{HexPaste, hex_paste, take_hex_pastes}; + + fn paste(text: &str) -> egui::Event { + egui::Event::Paste(text.into()) + } + + /// A paste with the caret before a `#` that stays never inserts bare digits ahead of it. + #[test] + fn bare_digits_are_not_inserted_before_a_hash_that_stays() { + assert_eq!(hex_paste("c", 3, false, false), HexPaste::Insert); + assert_eq!(hex_paste("c", 3, false, true), HexPaste::Ignore); + assert_eq!(hex_paste("fff", 3, false, false), HexPaste::Insert); + assert_eq!(hex_paste("fff", 3, false, true), HexPaste::Replace("fff".into())); + } + + /// Field text, caret, selected characters, pastes, text after, and pastes left for the text box. + type Case = (&'static str, usize, usize, &'static [&'static str], &'static str, usize); + + #[test] + fn pastes_at_the_start_of_a_hash_field_and_over_it() { + let cases: [Case; 5] = [ + ("#ab", 0, 0, &["fff"], "fff", 0), + ("#ab", 0, 0, &["c"], "#ab", 0), + ("#ab", 0, 1, &["c"], "#ab", 1), + ("#ab", 0, 3, &["fff"], "fff", 0), + ("#ab", 1, 0, &["c"], "#ab", 1), + ]; + for (text, start, selected, pastes, after, left) in cases { + let mut buf = text.to_string(); + let mut events: Vec = pastes.iter().map(|p| paste(p)).collect(); + take_hex_pastes(&mut events, &mut buf, start, selected); + assert_eq!((buf.as_str(), events.len()), (after, left), "{text:?} caret {start} selected {selected} pasting {pastes:?}"); + } + } + + /// A replacing paste drops the digits kept from earlier pastes in the same frame. + #[test] + fn a_replacing_paste_drops_earlier_inserts_in_the_frame() { + let mut buf = "a".to_string(); + let mut events = vec![paste("b"), egui::Event::Text("x".into()), paste("fff")]; + assert!(take_hex_pastes(&mut events, &mut buf, 1, 0)); + assert_eq!(buf, "fff"); + assert_eq!(events.len(), 1, "only the typed text is left"); + let mut buf = "ab".to_string(); + let mut events = vec![paste("c"), paste("d")]; + assert!(!take_hex_pastes(&mut events, &mut buf, 2, 0)); + assert_eq!((buf.as_str(), events.len()), ("ab", 2), "inserts alone stay for the text box"); + } +} diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index a9b56e091..1046e56a7 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -252,17 +252,32 @@ pub(crate) fn popover_is_open(ctx: &egui::Context, anchor: egui::Id) -> bool { ctx.data(|d| d.get_temp::(anchor.with("popover"))).is_some_and(|f| f + 1 >= frame) } -/// Records where the bottom bars (status and hint bars) start this frame, so menus can stop above -/// them (#2086). Without bars it is the bottom of the window. +/// Records where the bottom bars (the status and hint bars, and the Control bar docked at the +/// bottom) start this frame in the window being drawn, so its menus can stop above them (#2086). +/// Without bars it is the bottom of the window. pub fn set_menu_floor(ctx: &egui::Context, y: f32) { - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("menu_floor"), y)); + let id = menu_floor_id(ctx); + ctx.data_mut(|d| d.insert_temp(id, y)); } -/// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt. -pub fn menu_room(floor: f32, top: f32) -> f32 { +/// The floor's key: egui viewports share one context, and each OS window has its own bottom edge. +fn menu_floor_id(ctx: &egui::Context) -> egui::Id { + egui::Id::unique("menu_floor").with(ctx.viewport_id()) +} + +/// Where menus of the window being drawn stop: its recorded floor, or its bottom edge when nothing +/// recorded one, as in a floating panel's OS window. +pub(crate) fn menu_floor(ctx: &egui::Context) -> f32 { + let id = menu_floor_id(ctx); + ctx.data(|d| d.get_temp::(id)).unwrap_or_else(|| ctx.content_rect().bottom()) +} + +/// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt, +/// and never more than `tallest`, the most that fits in the window. +pub fn menu_room(floor: f32, top: f32, tallest: f32) -> f32 { const BOTTOM_GAP: f32 = 12.0; const MIN_HEIGHT: f32 = 120.0; - (floor - top - BOTTOM_GAP).max(MIN_HEIGHT) + (floor - top - BOTTOM_GAP).max(MIN_HEIGHT).min(tallest) } /// The scroll bar of a menu's list: slim, in a column of its own beside the items, so it never @@ -271,13 +286,14 @@ pub fn list_scroll_style() -> egui::style::ScrollStyle { egui::style::ScrollStyle { bar_width: 6.0, bar_inner_margin: 2.0, ..egui::style::ScrollStyle::solid() } } -/// The body of a menu or popup list: as tall as its items up to the bottom bars of the window, and -/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on -/// small windows. Its scroll bar has a column of its own, so it never covers the shortcuts or the -/// submenu arrows (#2086). +/// The body of a menu or popup list: as tall as its items up to the bottom bars of the window it +/// opens in, and scrolling only past that, so long menus (Window, Effect, a panel's menu) stay +/// reachable on small windows and in a floating panel's short OS window. Its scroll bar has a column +/// of its own, so it never covers the shortcuts or the submenu arrows (#2086, #5347). pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - let floor = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("menu_floor"))).unwrap_or_else(|| ui.ctx().content_rect().bottom()); - let room = menu_room(floor, ui.next_widget_position().y); + let ctx = ui.ctx(); + let tallest = (ctx.content_rect().height() - egui::Frame::popup(ui.style()).total_margin().sum().y).max(0.0); + let room = menu_room(menu_floor(ctx), ui.next_widget_position().y, tallest); ui.scope(|ui| { ui.spacing_mut().scroll = list_scroll_style(); // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index d38838782..e73a41707 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -1,5 +1,7 @@ use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; -use super::panel_chrome::chevron_cell; +use super::panel_chrome::{ + alt_down_in_field, chevron_cell, entry_to_focus, keep_enter_in_presets, open_presets_from_keys, presets_open, return_focus_from_presets, +}; use super::text::{ after_shift_enter, focus_field, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_armed_focus, take_dialog_focus, }; @@ -822,9 +824,10 @@ fn spin_generic( let h = 26.0; let enabled = ui.is_enabled(); ui.scope(|ui| { - // The spinner, the field and the chevron share their borders: one box in three parts. + // The spinner, the field and the chevron share their borders: one box in three parts. The + // spinner is not a Tab stop (↑/↓ in the field step it), so Tab goes from field to field. ui.spacing_mut().item_spacing.x = -1.0; - let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click()); + let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::CLICK); ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input); ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let up = Rect::from_min_max(sr.min, pos2(sr.right(), sr.center().y)); @@ -855,30 +858,41 @@ fn spin_generic( out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers, stops, fmt))); } let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; + let keyed = if presets.is_empty() { None } else { alt_down_in_field(ui, Rect::from_x_y_ranges(sr.right()..=sr.right() + fw, sr.y_range())) }; if let Some(v) = field(ui, fw, label) { out = Some(SpinPick::Value(v.max(min))); } if !presets.is_empty() { let dresp = chevron_cell(ui, h); + let keyed = open_presets_from_keys(ui, &dresp, keyed); + let was_open = presets_open(ui, &dresp); egui::Popup::menu(&dresp).show(|ui| { ui.set_min_width(width - 10.0); - if let Some(on) = auto - && ui.selectable_label(on, tl!("Auto")).clicked() - { - out = Some(SpinPick::Auto); - } - if let Some(on) = optical - && ui.selectable_label(on, optical_label.unwrap_or_default()).clicked() - { - out = Some(SpinPick::Optical); - } let current = value.filter(|_| auto != Some(true)); - for p in presets { - if ui.selectable_label(current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { - out = Some(SpinPick::Value(*p)); + let mut entries: Vec<(bool, String, SpinPick)> = Vec::new(); + if let Some(on) = auto { + entries.push((on, tl!("Auto").to_string(), SpinPick::Auto)); + } + if let Some(on) = optical { + entries.push((on, optical_label.unwrap_or_default().to_string(), SpinPick::Optical)); + } + entries.extend(presets.iter().map(|p| (current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p), SpinPick::Value(*p)))); + let focus = entry_to_focus(keyed, entries.iter().map(|e| e.0)); + for (i, (on, text, pick)) in entries.into_iter().enumerate() { + let r = ui.selectable_label(on, text); + if focus == Some(i) { + r.request_focus(); + } + if r.clicked() { + out = Some(pick); + // Enter or Space on the focused entry is a click too, which the menu + // does not close on by itself. + ui.close(); } } }); + keep_enter_in_presets(ui, was_open); + return_focus_from_presets(ui, &dresp); } }); out diff --git a/crates/ui-egui/src/widgets/panel_chrome.rs b/crates/ui-egui/src/widgets/panel_chrome.rs index 0dcad26fe..e07367dab 100644 --- a/crates/ui-egui/src/widgets/panel_chrome.rs +++ b/crates/ui-egui/src/widgets/panel_chrome.rs @@ -117,10 +117,78 @@ pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str) resp.on_hover_text(tip).clicked() } -/// The chevron cell on the right of a field that opens its preset list (rounded on the right). +/// The field at `field`, before a preset chevron, when it has the keyboard focus and Alt+↓ (with +/// no other modifier) was pressed. The key is taken before the field is drawn, so that the field +/// does not step on it; the caller then opens the preset list ([`open_presets_from_keys`]). +pub(super) fn alt_down_in_field(ui: &Ui, field: Rect) -> Option { + let focused = ui.memory(|m| m.focused())?; + let in_field = ui.ctx().read_response(focused).is_some_and(|r| field.expand(1.0).contains_rect(r.rect)); + let alt_down = |e: &egui::Event| matches!(e, egui::Event::Key { key: egui::Key::ArrowDown, pressed: true, modifiers, .. } if modifiers.matches_exact(egui::Modifiers::ALT)); + let pressed = in_field + && ui.is_enabled() + && ui.input_mut(|i| { + let before = i.events.len(); + i.events.retain(|e| !alt_down(e)); + i.events.len() != before + }); + pressed.then_some(focused) +} + +/// Opens the preset list of `chevron` when Alt+↓ was pressed in `field` ([`alt_down_in_field`]), +/// and remembers the field so that [`return_focus_from_presets`] gives it the keyboard focus back +/// once the list closes. Returns whether it opened the list. +pub(super) fn open_presets_from_keys(ui: &Ui, chevron: &Response, field: Option) -> bool { + let Some(field) = field else { return false }; + let popup = egui::Popup::default_response_id(chevron); + egui::Popup::open_id(ui.ctx(), popup); + ui.data_mut(|d| d.insert_temp(popup.with("return focus"), field)); + true +} + +/// Whether the preset list of `chevron` is open, read before the list is drawn for +/// [`keep_enter_in_presets`]. +pub(super) fn presets_open(ui: &Ui, chevron: &Response) -> bool { + egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(chevron)) +} + +/// Takes this frame's Enter presses after an open preset list was drawn: Enter picks the focused +/// entry and does not also reach the dialog's OK, as in [`super::dropdowns`]' combo boxes. +pub(super) fn keep_enter_in_presets(ui: &Ui, was_open: bool) { + if was_open { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } +} + +/// After the preset list of `chevron` was drawn: when a list that Alt+↓ opened has closed (an +/// entry was picked, or Escape or a click outside closed it), the field gets the keyboard focus +/// back, unless the focus has moved on to a widget outside the list. +pub(super) fn return_focus_from_presets(ui: &Ui, chevron: &Response) { + let popup = egui::Popup::default_response_id(chevron); + let key = popup.with("return focus"); + let Some(field) = ui.data(|d| d.get_temp::(key)) else { return }; + if egui::Popup::is_id_open(ui.ctx(), popup) { + return; + } + ui.data_mut(|d| d.remove::(key)); + // The list's entries are drawn in the popup's own layer, whose id is the popup id. + let elsewhere = ui.memory(|m| m.focused()).and_then(|id| ui.ctx().read_response(id)).is_some_and(|r| r.layer_id.id != popup); + if !elsewhere { + ui.memory_mut(|m| m.request_focus(field)); + } +} + +/// The entry of a preset list that gets the keyboard focus when Alt+↓ opened the list: the +/// chosen one, else the first. `None` when the list was not opened from the keys. +pub(super) fn entry_to_focus(opened_from_keys: bool, chosen: impl IntoIterator) -> Option { + opened_from_keys.then(|| chosen.into_iter().position(|c| c).unwrap_or(0)) +} + +/// The chevron cell on the right of a field that opens its preset list (rounded on the right). It +/// is not a Tab stop: Tab goes from the field to the next field, and Alt+↓ in the field opens the +/// list ([`alt_down_in_field`]). pub(super) fn chevron_cell(ui: &mut Ui, h: f32) -> Response { let t = Tokens::get(ui.ctx()); - let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); + let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::CLICK); ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let c = if !ui.is_enabled() { diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index a8aa9e802..3551baebd 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -1,6 +1,8 @@ use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; use super::numeric::mixed_field_named; -use super::panel_chrome::chevron_cell; +use super::panel_chrome::{ + alt_down_in_field, chevron_cell, entry_to_focus, keep_enter_in_presets, open_presets_from_keys, presets_open, return_focus_from_presets, +}; use super::{Rect, Response, Stroke, StrokeKind, Tokens, Ui, id_with, vec2}; /// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the @@ -380,16 +382,30 @@ pub fn text_presets_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&s ui.scope(|ui| { // The field and the chevron share a border. ui.spacing_mut().item_spacing.x = -1.0; + let at = ui.cursor().min; + let keyed = alt_down_in_field(ui, Rect::from_min_size(at, vec2(width - 19.0, 26.0))); out = text_field_named(ui, &id, name, Some(value), width - 19.0, 1); let resp = chevron_cell(ui, 26.0); + let keyed = open_presets_from_keys(ui, &resp, keyed); + let was_open = presets_open(ui, &resp); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(width - 10.0); - for p in presets { - if ui.selectable_label(*p == value, *p).clicked() { + let focus = entry_to_focus(keyed, presets.iter().map(|p| *p == value)); + for (i, p) in presets.iter().enumerate() { + let r = ui.selectable_label(*p == value, *p); + if focus == Some(i) { + r.request_focus(); + } + if r.clicked() { out = Some((*p).to_string()); + // Enter or Space on the focused entry is a click too, which the menu does not + // close on by itself. + ui.close(); } } }); + keep_enter_in_presets(ui, was_open); + return_focus_from_presets(ui, &resp); }); out } diff --git a/crates/ui-egui/src/widgets/toggles.rs b/crates/ui-egui/src/widgets/toggles.rs index 8dac694ec..5cac2023e 100644 --- a/crates/ui-egui/src/widgets/toggles.rs +++ b/crates/ui-egui/src/widgets/toggles.rs @@ -26,7 +26,12 @@ pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { /// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled. pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool { - let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled); + check3_tip(ui, label, Some(value), enabled, tip) +} + +/// [`check3`] with tooltip `tip` on the box and its label. Returns true when clicked. +pub fn check3_tip(ui: &mut Ui, label: &str, value: Option, enabled: bool, tip: &str) -> bool { + let (clicked, resp) = check_row(ui, tl!(label), value, enabled); resp.on_hover_text(tip); clicked } diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 28abe518b..d7590ff3b 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -21,6 +21,9 @@ use crate::{VectorcraftApp, widgets}; pub struct Workspace { pub name: String, pub control_bar: bool, + /// The Control bar is docked to the bottom of the window (built-in workspaces, and workspaces + /// saved without this field: the top). + pub control_bar_bottom: bool, pub rulers: bool, pub toolbar: bool, pub toolbar_double: bool, @@ -175,6 +178,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { Workspace { name: name.into(), control_bar: ui.control_bar, + control_bar_bottom: ui.control_bar_bottom, rulers: ui.view.rulers, toolbar: ui.toolbar, toolbar_double: ui.toolbar_double, @@ -201,6 +205,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { /// Apply a workspace's layout flags (brightness, prefs, shortcuts etc. untouched). pub fn apply(ui: &mut UiState, w: &Workspace) { ui.control_bar = w.control_bar; + ui.control_bar_bottom = w.control_bar_bottom; ui.view.rulers = w.rulers; ui.toolbar = w.toolbar; ui.toolbar_double = w.toolbar_double; @@ -514,7 +519,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0)); } ui.add_space(16.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // As wide as the fields and as tall as the buttons, as in `dialogs::show`: a right-to-left + // layout would take all the height left in the window and center the buttons in it. + 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(ui, tl!("OK")).clicked() { ok = true; } @@ -766,4 +774,23 @@ mod tests { let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap(); assert_eq!(back.custom_workspaces[1].dock_width, Some(260.0)); } + + /// A workspace keeps the edge the Control bar is docked to, and one saved without it docks the + /// bar at the top. + #[test] + fn saved_workspaces_restore_the_control_bar_edge() { + let mut ui = UiState { control_bar: true, control_bar_bottom: true, ..Default::default() }; + save_as(&mut ui, "Bottom").unwrap(); + switch(&mut ui, ESSENTIALS).unwrap(); + assert!(!ui.control_bar_bottom, "Essentials docks the bar at the top"); + switch(&mut ui, "Bottom").unwrap(); + assert!(ui.control_bar_bottom, "the saved workspace docks it at the bottom"); + let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap(); + assert!(back.control_bar_bottom && back.custom_workspaces[0].control_bar_bottom, "both survive the preferences' JSON"); + let mut old = serde_json::to_value(&back.custom_workspaces[0]).unwrap(); + assert!(old.as_object_mut().unwrap().remove("controlBarBottom").is_some()); + let old: Workspace = serde_json::from_value(old).unwrap(); + apply(&mut ui, &old); + assert!(ui.control_bar && !ui.control_bar_bottom, "an older workspace docks the bar at the top"); + } } diff --git a/docs/contributors.md b/docs/contributors.md index a51eaf20c..e63d7a1cc 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers. - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. Never hand-edit. + username. Never hand-edit. Release builds regenerate it for the release commit + (`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0a9bfd9f0..4e8408cd5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -685,7 +685,16 @@ art too large at that scale) keeps the dialog open. Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?}` for agents) opens the `placementOptions` dialog for the selected images (fields `ids`, `preserve`: `transforms`/`bounds`/`fileDimensions`/ `fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as -one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's +one undo step. + +Relink: the Links panel's Relink… button and flyout item (or `ui.relinkDialog {ids?}` for agents) open the `relink` +dialog for the selected images and placed documents (fields `ids`, `links`: `[{name, ids}]`, one row per linked file, +`scope`: `each`/`selected`/`all`/`missingInFolder`, default `selected`, `path` and `paths`). With nothing selected and +no `ids`, it fails with "select the images to relink, or pass ids". `ui.dialog.set {field: "path", value}` then `ui.dialog.confirm` runs +`links.relink {ids, path, scope}` as one undo step; without `path` a file is picked. With `scope: "each"`, each confirm +takes `path` (or picks a file) for the next row of `links`, adds it to `paths` and keeps the dialog open, returning +`{next}` (the next row's name); the last row's confirm runs `links.relink {scope: "each", paths}` for every row as one +undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's `links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to the system and return `{path}`. Edit Original opens only a regular file whose extension is a format `document.open` reads, and answers a control request with an error unless the file is a placed document (which opens in a tab). diff --git a/docs/dimensions.md b/docs/dimensions.md index b8ece996a..e55e698f2 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -36,8 +36,18 @@ changes); moved together with its art, it keeps its distance. Native `.vectorcraft` files keep the recipes and links. SVG, PDF and bitmap output contain ordinary paths and text, so exported dimensions look the same. Ungrouping a dimension leaves its -paths and text as ordinary art. Pasted dimensions keep their geometry and drop their links, so -they never bind to unrelated art in another document. +paths and text as ordinary art. Object › Expand, Object › Expand Appearance, Type › Create Outlines +and Object › Path › Outline Stroke turn a dimension whose art they change into a plain group of that +art, also when they reach only a part of it: moving or scaling the group does not redraw it, it does +not follow the art it measured, and undoing the command gives the live dimension back. The menus dim +Object › Expand… while a whole dimension is selected, because its label has appearance items; +Object › Expand Appearance and the `object.expand` command still reach it. A dimension selected with +the art it measures also becomes a plain group when Object › Expand, Object › Expand Appearance or +Object › Path › Outline Stroke changes only that art and the art no longer fits the dimension's link, +for example because its number of anchors changed. When the art keeps its id and anchor count and the +dimension's own art does not change, the dimension stays live and its label shows the new length. Pasted +dimensions keep their geometry and drop their links, so they never bind to unrelated art in another +document. ## Commands diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2307add80..6fe07e086 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -180,7 +180,7 @@ 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), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | -| Materials panel, lighting, ray-traced render | 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; no materials, shadows or ray tracing | +| 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; no materials, shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | none | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f60d46f62..805bbc63a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | -| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -66,7 +66,8 @@ private editing data. VectorCraft: their fills and strokes (a compound path as one path of all its subpaths), process, spot and global colors, swatches and color groups, guides, hidden and locked objects, object names, every artboard with its name, the active artboard (`CropAreaActive`: the view's active artboard when the app saves or exports the file, else the one the document opened on, else the first; - the reader opens the file on it), the transparency of objects and layers + the reader opens the file on it, and so does the restore of the native document a file VectorCraft saved carries), + the transparency of objects and layers (opacity, blending mode, isolation, knockout and knockout shape), objects' opacity masks, and clipping groups and clipping layers clipped by a path or compound path, linear and radial gradients on fills and strokes (with their stops' opacity, midpoints and spot colors, elliptical radials and focal points) and gradient swatches, and @@ -75,21 +76,53 @@ private editing data. VectorCraft: RGB or CMYK ink amounts, and their alpha channel) and linked images as placed files with their path, their folder relative to the document's and a preview; images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray, images of 16 bits a channel at 8 bits, and linked images in opacity - masks and pattern tiles as embedded images, each with a warning. Point type, area type (with its rows, columns, + masks, pattern tiles and symbols as embedded images, each with a warning. Every symbol is written as a symbol + definition (its art, its registration point and its place in the Symbols panel's order, nested symbols included) + and its instances as symbol instances with their matrix, name, hide, lock, opacity, blending mode and opacity + mask; instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of + symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, + instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, + instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art, + instances that scale their symbol more than 100 times and instances on the layers of a large canvas are written as their art, pattern fills in a + symbol's art are written as their tiles cut to the shape, symbols past 4,096 are written only as their + instances' art, and symbols' 9-slice guides, export types and dynamic type are left out, each with a + warning. Paths, compound paths and groups with live effects keep them as art styles: the drawn look, the object + itself and the style it names, for a fill and a stroke at most in solid colors or the object's gradient (a stroke + with its variable-width profile), filled by the non-zero rule, with Drop Shadow, Inner Glow, Outer Glow, Feather, + Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object or Outline Stroke, and groups with Pathfinder › + Add. Groups keep them when their members are paths, compound paths or such groups, without effects of their own; when the + group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern + fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with + live effects are written as their vector art with their geometry effects applied, and their other effects, such as + Drop Shadow, the glows and Feather, are left out with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large + canvas. + Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, scales, solid fills and strokes, capitals and paragraph alignment, indents, spacing and direction; other character - and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles and art cut to a + and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, turned or unevenly scaled area type and type on a path, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation, type and stories past the sizes VectorCraft's reader reads, and - type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, symbols, other clipping objects and live + type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, other clipping objects and live objects (placed documents included) are written as plain filled and stroked paths, and freeform gradients, gradients along or across a stroke and gradients without stops, with more than 256 stops or without extent as their average color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic gradients, and gradient stops linked to global process colors as their colors, each with a warning. The opacity and blending mode of single fills and strokes, opacity masks on layers, the placement of pattern swatches that - move, turn or scale their pattern, and effects other than geometry effects are left out. + move, turn or scale their pattern, and the effects of objects written without an art style, other than geometry + effects, are left out. + The editing copy also holds the document's variables, data sets, the data set shown and the variables' lock as + the variable library the reader reads, with the objects bound to visibility variables, the type bound to text + variables and the linked images bound to image variables. Bindings to layers, to type written as outlines and to + graphs, graph values, values that don't fit their variable, linked files that aren't absolute paths, the path `/` + alone and paths from the root whose first part starts like a drive (`/C:`), drive and network paths with white + space at either end, values of variables whose names have a colon, and a data set shown that isn't one of the data + sets written are left out, with a + warning, as are variables and data sets whose names are empty, repeated, longer than 255 characters or start or + end with white space, variables past 256, data sets past 1,000, bindings past 100,000 or to objects whose XML ids are + longer than 255 characters, values past 200,000 text lines and values in all, and names and values past + 8,388,608 bytes in all ([variables](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/work-with-variables.html)). - **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0 ([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its @@ -122,6 +155,11 @@ private editing data. VectorCraft: brushes come in as their drawn look (#637; pattern fills are read since #1025, and symbols and their instances open live); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides open as ruler guides and guide paths since #5057 (#779). + Type under a Warp effect is compared with the art saved with it: type that draws as that art stays editable with + the effect live, and type that draws differently, or that the file's limit of checks has no check left for, opens + as that art, with one note that counts them. Type stays live without a comparison when the saved art paints + nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text + object doesn't come in as one text object. Legacy `.ai` type keeps its paragraphs' letter and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`); its tab definitions (`Xb` … `XB`) set its paragraphs' tab stops. Paragraphs in a row with the same attributes share one copy of them while the text object is read, and those copies hold at most @@ -129,7 +167,17 @@ private editing data. VectorCraft: stops. Paragraphs and frames past either limit take the default stops, with a warning. Its special characters (`TS`) join the story, and end render (`Te`) and pop text matrix (`TM`) are read and ignored. Its type in a font re-encoded with encoding pairs of its own (`TZ`) is decoded with those pairs, and with the file's encoding - (`TE`) for the codes they leave out unless the `TZ` sets `useDefault` to 0. + (`TE`) for the codes they leave out unless the `TZ` sets `useDefault` to 0. Its type in a CJK composite font, + whose name ends in a predefined CMap (`HeiseiMin-W3-83pv-RKSJ-H`) and which no `TZ` gives encoding pairs, opens + with the font named without the CMap. Its codes are read by the CMap's code space, mapped to CIDs and then to + Unicode by the character collection's CID-to-Unicode CMap, and codes the CMap does not map are dropped. A `-V` + CMap is read with its `-H` form's codes, and a 0x0D byte ends a paragraph. The Unicode CMaps `Uni*-UCS2`, `-UTF8`, + `-UTF16` and `-UTF32` are read as Unicode. VectorCraft reads the predefined CMaps that craft-pdf does not carry + (`RKSJ`, `78-RKSJ`, `78ms-RKSJ`, `78-EUC`, `78`, `Add`, `Ext`, `NWP`, `GB`, `CNS1`, `KSC`, `B5` and the Hong Kong + `-B5` sets other than `HKscs-B5`) with its own choice of the nearest carried CMap, which can give a different + character for some codes, and decodes `GBT`, `GBT-EUC`, `GBTpc-EUC`, `CNS2` and `KSC-Johab` type byte by byte with + the CMap removed from the font name. A font whose name ends in `-H` or `-V` without a `TZ` that gives encoding pairs + is read with the `H` or `V` CMap. Its area type with several linked areas opens as threaded frames, one for each area with its own matrix, and the story flows through them. Its writing direction (`TV`) opens as vertical type. VectorCraft keeps one direction for each text object, the @@ -139,7 +187,9 @@ private editing data. VectorCraft: block in vertical type opens unmarked, and two blocks with nothing between them open as one block. Its upright Roman characters (`Tv`) open with Standard Vertical Roman Alignment. VectorCraft's own layout rule also sets a run of two or three half-width digits across the column in vertical type, inside or outside a block and with - either `Tv` value. + either `Tv` value. Its binding punctuation (`Tg`) opens as Burasagari's Force setting when on and as no + Burasagari when off, which is VectorCraft's own mapping; the repeat character, line breaking (`TG`) and + Japanese layout rules (`Xu`, `Tu`) are not read. Legacy `.ai` type set in rendering modes 4 to 7 (`Tr`, text used as a mask) is a clipping path of its clipping group, as a `W` path is, and the mode lasts into later text objects. Type in linked areas, in wraparound text or in a compound path doesn't clip, with a warning. A `Tr` operand that is not a whole number @@ -163,9 +213,26 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do; a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path comes from VectorCraft's font metrics, and on Arial at 24 pt it differs from the distance the file keeps by 1.1 pt (Ascender), 0.3 pt (Center) and 2.7 pt (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip, and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. + ## Format by format | Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests | diff --git a/docs/gaps.md b/docs/gaps.md index 4dac9431e..dc6971cf5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -184,7 +184,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users -- **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; 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. +- **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 comes from VectorCraft's font metrics, 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. @@ -203,7 +203,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, - clipping and type as text), with live effects left out and symbols and live objects written as their art + clipping, type as text and symbols), with live effects kept only on 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, other objects' effects other than geometry effects left out, and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..9220b1c62 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -380,8 +380,14 @@ explicit `ids`, which stand in for the selection, so they work with nothing sele `ids?|id?` (or `id?, ids?: … (instead of id)`), `id` is `ids` of one object: `{"id": 7}` does what `{"ids": [7]}` does, with or without a selection, and an `id` or `ids` that is malformed (not a non-negative integer) or names no object is refused under the command's name, never replaced by the selection (`effect.apply {"id": "warp.arc"}` -without `effect` names the effect). 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. +without `effect` names the effect). Other commands that act on `id` or else the selection, the highlighted layer row +or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseToLayers`, `graphicStyle.new`, +`graphicStyle.redefine`, `charStyle.new`, `charStyle.apply`, `charStyle.redefine` and their `paraStyle.*` +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 +selected group also leaves it out when `ids` list it together with a group that holds it. Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`, or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`). @@ -410,7 +416,7 @@ Lines start with no fill and a visible stroke. The Fill/Stroke proxy commands run through `run_command`: `paint.invert` and `paint.complement` recolor the active proxy, including a grayscale image's colorized fill, while keeping each color's model. `paint.lastColor` / `paint.lastGradient` re-apply the last solid color or gradient, and `paint.recent` returns the recent colors that every -paint command (and the eyedropper) feeds. +paint command (and the eyedropper) feeds: up to 30, newest first and each once, kept across documents for the session. `paint.proxies` returns what the proxies show: the fill and stroke, which one is active, and whether the selected objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" proxy). @@ -446,7 +452,7 @@ opens the picker through `run_command` with the remote backend. | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | -| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | +| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | For example, create editable Japanese area type with the engine's vertical writing mode: @@ -534,6 +540,21 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o `effect.remove`, `effect.setParams` (`visible` toggles one) and `effect.duplicate` address that item's effects; `apply_effect` uses the active item. `effect.list` reports each object's item effects under `applied[].items`. +On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, +`appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, +`appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer` and `appearance.copyFrom` store the paint, +weight and opacity of the topmost visible fill and stroke on the graph. When the graph is drawn again, columns, bars, +wedges, areas, markers and legend swatches take the stored fill and stroke. A thin line takes the stored stroke and has +no fill. A Filled Lines band takes the stored fill, or the stored stroke when no fill is stored and that stroke has +paint, and has a 0.25 pt black outline, so these commands do not store a stroke paint or weight for a series that +draws a band. They still store a stroke opacity set on a band, and the band's outline takes it. Graph +designs keep their own paint. A row that had no paint before the command and has none after it does not change what the +graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other +store the topmost row of the kind even when it is hidden, and store a stroke set on a band. +`stroke.set`, `paint.editGradient`, `paint.swap`, `paint.default`, graphic styles and the recolor commands do not store a +series' paint. An `appearance.*` command given a series group's id edits the group's own rows, and the graph does not +store them. + `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape those outlines. On paths, compound paths and images it does not change what is drawn. @@ -647,6 +668,10 @@ them. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, Convert to Grayscale before the switch would give); other colors convert as usual. The reply's `changed` counts the colors that changed in the art, symbols, graph designs and pattern tiles; it does not count the colors of graphic styles and brushes. +`edit.colorSettings`, `file.documentColorMode`, `color.convert`, `color.gamutCheck`, `color.loadProfile`, +`color.removeProfile`, `view.proofSetup`, `view.proofColors`, `view.overprintPreview`, `view.separationsPreview` and +`swatch.setSpot` reject a param of the wrong type, such as `"bpc": "no"`, `"on": 1`, `"to": 5` or `"colors": "#fff"`, +and change nothing. Omitting a param or giving null keeps its default. SVG import keeps what the canvas edits live. `` becomes a pattern swatch. A `` with `` becomes a symbol (named after its `data-name`, else its id) with an instance per ``; a `` that shows it differently @@ -821,6 +846,9 @@ empty `boxes`, and `thumbnail: 1` shows what that page prints. A page whose `Use opens at its size in points, the file's numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size. +`document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` +that is not a string, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number. The error names the param, +and nothing opens or is placed. Leaving a param out or giving null keeps its default. Imported colours keep their model: DeviceCMYK and CMYK ICC colours stay CMYK (a file painted mostly in CMYK opens as a CMYK document), DeviceGray is Gray, and Separation and DeviceN inks become spot swatches the art links to at its tint (gradient stops too). CMYK images @@ -875,6 +903,8 @@ document exactly (`restored: true`); when another app changed the pages, or the imported instead and the first warning says why. Choosing a standard turns it off (PDF/A refuses it). Save to a `.ai` path (`document.save {path: "art.ai"}` or `{format: "ai"}`) writes a PDF-compatible file that always carries the document, takes the PDF options and keeps its path when reopened, so Save writes `.ai` again; a `.ait` opens untitled. +The restored document opens on the artboard its editing copy names as active. A file saved with `editingData: false` has +no editing copy and opens on the first artboard. `document.export` / `serialize` to `.ai` (and the CLI's `run --export x.ai` and `convert in.svg out.ai`) write the same file without giving the document a path. `options: {aiVersion: "7"}` writes legacy `.ai` files of version 7 instead (the PostScript form, which keeps layers, groups, compound paths and clipping groups in apps that read it), one per artboard @@ -1080,10 +1110,12 @@ PDF 1.3 export. ### Parameter keys -`command_run`, each step of `command_batch`, `run --cmd … --params` and the control channel's `engine.execute` check -the keys of `params` against the command's params description (the `params` text `command_list` returns): the object -it starts with, `{x, width?: pt, name?|title?: …}`, names the keys; `?` marks an optional one. A key it doesn't name -is refused before the command runs: +The engine checks the keys of `params` of every command it runs against the command's params description (the +`params` text `command_list` returns): `command_run`, each step of `command_batch` and of the `command.batch` and +`action.batch` commands, `run --cmd … --params`, the control channel's `engine.execute`, and the app's own calls. A +batch with such a step fails before its first step runs. The object the description +starts with, `{x, width?: pt, name?|title?: …}`, names the keys; `?` marks an optional one. A key it doesn't name is +refused before the command runs: ```json {"id": "file.new", "params": {"widht": 300}} @@ -1126,13 +1158,17 @@ one opens as before and draws it as `rgb`: ``` A number outside the range the effect documents is still stored as given and clamped when the effect draws. A -plug-in effect's parameters are resolved against its manifest instead, and `effect.setParams` as a step of -`command.batch` isn't checked before the batch runs (the effect it edits may only exist once the steps before it ran); -the command still refuses an effect `color` it can't read. A description with a `…` entry takes more keys than it lists: those of the command it names +plug-in effect's parameters are resolved against its manifest instead. `effect.setParams` as a step of +`command.batch` is checked when the step runs, against the effects the steps before it added, and the batch then +fails with no change. A description with a `…` entry takes more keys than it lists: those of the command it names (`…settings as file.new`), or any key when it names none, as `document.export` and `document.save` do for the options of the format they write (one 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 journal isn't checked: an action or -journal recorded by an older version replays as it did, except for an effect `color` that the effect can't read. +(`document.setup`, `text.areaOptions`) says so the same way. + +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 +`color` or `channel` it can't read. An unknown command id is matched the same way as an unknown parameter key: the error names the nearest command id when one is near, ignoring case. A step of `command.batch` or `action.batch` gets the same suggestion in its step error: @@ -1160,7 +1196,7 @@ These Object menu commands refuse a string, a number or another value that is no `` `copy` must be true or false ``, naming the key, and change nothing: `reflectX`, `reflectY`, `random`, `copy`, `relative`, `strokes`, `corners`, `objects` and `patterns` in `object.transformEach`; `gray` and `deleteRaster` in `object.createObjectMosaic`; `trim` in `object.cropImage`; `allArtboards` in `object.createTrimMarks`; and `byColumn`, -`moveArtwork` and `patterns` in `artboard.rearrange`. A null is read as a key left out. `object.createTrimMarks` +`moveArtwork`, `patterns` and `lockedAndHidden` in `artboard.rearrange`. A null is read as a key left out. `object.createTrimMarks` refuses a `style` that is not a string, such as `5`, with `` `style` must be roman or japanese ``, the message it gives for a string that names no style. @@ -1204,6 +1240,14 @@ in `object.envelope.resetWithMesh`; `antiAlias`, `distortAppearance`, `distortLi `distortPatternFills` in `object.envelope.options`; and `editing` in `object.envelope.editContents`. A null is read as a key left out. +`object.envelope.makeWithWarp`, `resetWithWarp` and `options` refuse a `style` that is not a string with +`` `style` must be a string `` and a name that is no warp style with `` unknown warp style `foo` ``, and an +`orientation` other than `horizontal` or `vertical`, a number or `true` included, with +`` `orientation` must be one of horizontal, vertical ``. `object.envelope.options` refuses a `preserveShape` that is +not a string with `` `preserveShape` must be a string ``, and checks the warp keys when no envelope or only mesh +envelopes are selected, too. Each leaves the document, its undo history and the options new envelopes start with as +they were. A null is read as a key left out. + These commands refuse a string, a number or another value that is not true or false with `` `closed` must be true or false ``, naming the key, and leave the document and its undo history as they were: `clockwise` in `shape.spiral`; `closed` in `shape.arc` and `path.create`; `vertical` and `placeholder` in `text.create` @@ -1694,6 +1738,13 @@ already walks Image Trace. Short agent entry points: `symbol.selectInstances {name}`, `symbol.delete {name | names, expandInstances?}` (`names` deletes several in one undo step). `symbol.new` and `symbol.duplicate {name, newName?}` trim the white space around the name they are given, as `symbol.options` trims `newName`; a blank one gives "New Symbol" or " copy". +- Reset Transformation (the Symbols panel menu, and Reset on the Control bar while symbol instances are selected): + `symbol.instance.resetTransform {ids?}` gives the selected symbol instances, in groups too, their symbol's own + scale again, without rotation, shear or reflection, as one undo step. VectorCraft's own rules: each instance keeps + the center of its bounding box in place; other objects, instances in perspective and instances already at their + symbol's scale are left as they are, and no undo step is added when nothing changes; an id that names no object, or + objects without a symbol instance, is an error. It returns `{ids}`, the instances it changed. The menu item and the + button are enabled only while a selected instance would change. - Dynamic symbols: an instance of a symbol whose `type` is `dynamic` changes the appearance of objects in the symbol's art for itself only and stays linked. `symbol.instance.setAppearance {id?, path, fill?, stroke?, opacity?, blend?}` names the object by `path`, child indices in paint order from the art's root down through its groups (`[]` is the @@ -1737,7 +1788,9 @@ already walks Image Trace. Short agent entry points: what that kind of corner draws when the brush has no `outer_corner` or `inner_corner` art for it: `none` draws no corner tile, and `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap` make one from the side tile. Without a source, the corner draws the other kind of corner's art. `brush.options {params: {outer_corner: null}}` removes - the art. Pattern Brush Options also accepts `side_tile`, `outer_corner_tile`, `inner_corner_tile`, + the art. Outer and inner corner art are both drawn as the corner that joins a side tile on its right to one below + it. The brush turns each corner tile so that those two arms meet the sides at the corner: 90° from one corner to + the next on a rectangle, and halfway between the two sides at other angles. Pattern Brush Options also accepts `side_tile`, `outer_corner_tile`, `inner_corner_tile`, `start_tile` and `end_tile` in `params`. Each takes `none`, `original` or the name of a pattern swatch in the active document, and `{"pattern": ""}` names a swatch even when its name is one of those words. Corner tiles also take `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap`. A swatch choice copies its whole art and tile frame into the brush. `original` @@ -1844,11 +1897,13 @@ already walks Image Trace. Short agent entry points: the file at `path`, else a picked one, or the file's text `data`, adds its set after the other sets and returns `{set, name, actions}`. A set named like one already in the panel gets the next free number (`Print 2`), and the other sets and the current set stay as they are. An action set file (`.vcactions`) is JSON: - `{"format": "vcactions", "version": 1 or 2, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step - a command id and its params as `command.batch` takes them. `excluded: true` preserves a step but omits it from playback; - an absent or false flag includes the step. Files with excluded steps use version 2, so older builds refuse them - instead of playing the excluded steps; files without exclusions use version 1. A file with another `format` or a `version` other than - 1 or 2, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are + `{"format": "vcactions", "version": 1, 2 or 3, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step + a command id and its params as `command.batch` takes them, or a stop: the command `action.stop` with + `{message, allowContinue}`. `excluded: true` preserves a step but omits it from playback; + an absent or false flag includes the step. Files with stops use version 3, and files with excluded steps and no stops + use version 2, so older builds refuse them + instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than + 1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are refused, and no set is added. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is disabled where there is no open dialog (the web). - Actions: `ui.action.rename {set, action, name}` renames the Actions panel's action at `action` in set `set` (0 is @@ -1891,7 +1946,29 @@ already walks Image Trace. Short agent entry points: action has, or that more than one action in the sets looked in has, or whose every step is excluded, is an error, and then nothing plays. So is an action played while another is still playing at Step by Step or Pause For. A `command.batch` step, which an action played or a panel operation run as one batch records, plays as its own steps, - here and in Batch…. + here and in Batch…. An action with an included stop plays up to the stop at any speed and then waits, with its + message box open, until `ui.action.continue` or `ui.action.stop`. At Accelerated the steps between two stops are one + undoable batch each. The result is then `{results, pending, atStop?}`, with `atStop: true` when the action already + waits. Batch… refuses an action with an included stop, and a stop that is excluded does not hold playback. +- Actions: `ui.action.insertStop {set?, action?, step?, message?, allowContinue?}` inserts a stop. With `set` and + `action`, the stop goes into that action, after step `step`, else after its last step, and the command returns + `{set, action, step}`. Without them it goes into the recording in progress, after the steps recorded so far, and + returns `{recording: true}`. `message` is the text playback shows (default: none), and `allowContinue` (default: + false) offers Continue in the message box. An action with an empty message still waits at the stop, which is + VectorCraft's own rule. A stop in an action becomes the panel's selected step, and its action and set become the + selected ones. An index past the last set, action or step, a param of another type, params that are not an + object, or no action and no recording is an error, and then nothing changes. The panel menu's Insert Stop… opens the `actionStopRecord` dialog + (`message`, `allowContinue`, `set`, `action`, `step`), whose OK runs this. +- Actions: `ui.action.continue {}` plays on from the stop the playing action waits at, on the document active now, and + closes the stop's message box (`actionStopMessage`). The next step runs at once, or at Accelerated the steps up to + the next stop as one undo step. It returns `{results, pending, atStop?}`. With no action waiting at a stop it is an + error. The panel's Play Current Selection runs this while an action waits. In the message box, Continue (shown with + Allow Continue) runs this, and Stop closes the box while the action keeps waiting for the manual task. +- Actions: `ui.action.deleteStep {set, action, step}` deletes one step, a stop or a command, of the action at `action` + in set `set` (0 is the top set, action or step), and returns `{set, action, step}`. The steps after it keep whether + they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one + open on a later step of the action follows it up. A missing or invalid index is an error, and then nothing changes. + Undo does not restore a deleted step. The panel's Delete Selection runs this for the selected step. - Actions: `ui.action.playbackOptions {speed?, seconds?}` sets how the Actions panel plays actions and returns `{speed, seconds}`. `speed` is `accelerated` (the default: every step at once), `step` (one step at a time, with the window drawn after each step before the next runs) or `pause` (a pause of `seconds` between steps). `seconds` is a @@ -1901,14 +1978,16 @@ already walks Image Trace. Short agent entry points: this. Batch… (`action.batch`) plays every step at once whatever the setting. - Actions: `ui.action.stop {}` ends the recording in progress and keeps its steps, in the action `ui.action.new` made for it, else in a new action after the last action of its set, and returns `{stopped}`. It also ends an action - playing at Step by Step or Pause For: the steps already played stay, and the rest do not play. With nothing + playing at Step by Step or Pause For, or one waiting at a stop, whose message box closes: the steps already played + stay, and the rest do not play. With nothing recording or playing it changes nothing. The panel's Stop Playing/Recording button runs this. - Action sets: `ui.actionSet.delete {sets?: [index], ids?: [id]}` deletes the Actions panel's sets at these indexes (0 is the top set) with their actions, by default the current set, and returns `{deleted}`. An index past the last set is an error, and then no set is deleted. `ids` holds the panel's own set ids, which its question passes: the sets with these ids are deleted wherever they are now and `sets` is not read; when one of them no longer exists, the command returns an error and deletes no set. A recording into a deleted set stops and keeps no steps; a recording into another set - goes on in that set. The panel's Delete Selection (the trash button and the panel menu's Delete) asks before it runs + goes on in that set. The panel's Delete Selection (the trash button and the panel menu's Delete) deletes a selected + step (`ui.action.deleteStep`), else a selected action. Otherwise it asks before it runs this for the selected sets, and Shift-click on a set's name selects the sets from the current one to it. Undo does not restore a deleted set. `ui.actionSet.duplicate {set?: index, action?: index}` copies the action at `action` in set `set` to right after it @@ -2150,6 +2229,21 @@ The document stores their numeric settings; manual edits remain possible through {"name":"run_command","arguments":{"command":"effect.lightingPreset","params":{"index":0,"item":null,"preset":"topLeft"}}} ``` +## 3D object type + +`effect.setParams {index: int, threeDType: "plane" | "extrude" | "revolve" | "inflate", params?, item?, ids?, aligned?}` +changes an applied 3D effect into Rotate, Extrude & Bevel, Revolve or Inflate in place, as the object types of +**Window › 3D and Materials** do ([3D and Materials panel](three-d.md#3d-and-materials-panel)). The settings both +effects take keep their values (rotation angles are wrapped into -180..180 when the new type is not Revolve), the +settings only the new effect takes start at its defaults, and `params` then merge into the new effect's settings, +checked as that effect's. It is one undo step. Any other `threeDType` value, or an effect at `index` that is not a +3D effect, is an error and changes nothing. The type the effect already has, with no `params`, `visible` or +`warpStyle`, changes nothing, records no undo step and returns `{ids: []}`. + +```json +{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"threeDType":"inflate","params":{"volume":70}}}} +``` + ## Extrude & Bevel, Inflate and Rotate `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies @@ -2938,6 +3032,8 @@ for which Object → Expand… is dimmed in the menus; `symbolArt: {fill, stroke holds symbol instances that `object` expands, whether only their art holds it, which Fill and Stroke reach only with `object` on), and `object.mesh.create` on a gradient-filled object colours the mesh points as the gradient paints them, with the colors Object › Expand gives them. +`object.expand`, `effect.expandAppearance`, `type.createOutlines` and `object.path.outlineStroke` turn a dimension whose +art they change into a plain group of that art, in the same undo step (see [dimensions.md](dimensions.md)). ```json {"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}} @@ -3088,8 +3184,13 @@ the path it had begun while that stays selected. Its reply's `tool` is the tool `object.path.simplify` takes the amount as `tolerance` (points, 1 by default) or as the Simplify slider's `level`, a whole number from 0 (the fewest anchor points) to 100 (the closest fit). Level 50 is a tolerance of 1 pt, and every 10 levels halve or double it, so level 0 is 32 pt and level 100 is 1/32 pt. `auto: true` (Auto-Simplify) uses the lowest level -whose tolerance is at most 1/400 of the selection's longer side, or level 100 when that side is under 12.5 pt. With -`level` or `auto`, the reply also has the `level` used: +whose tolerance is at most 1/400 of the selection's longer side, or level 100 when that side is under 12.5 pt. The +result and the original path stay within `tolerance` of each other, except that a closed subpath that refits to one curve +is dropped, so a small circle in a compound path can be removed at a large `tolerance`. A segment that would need more +than 65,536 samples at that tolerance stays as it is, and so does a segment after a run's first 16,777,216 samples or +2,097,152 result segments. With `straightLines`, such a segment becomes evenly spaced lines, up to 65,536, and past those +limits the lines can be farther than `tolerance` from the segment. With `level` or `auto`, the reply also has the `level` +used: ```json {"name":"run_command","arguments":{"command":"object.path.simplify","params":{"auto":true}}} @@ -3169,8 +3270,9 @@ the preference `japaneseCropMarks` is on or `style: "japanese"` is given. PDF export draws printer's marks and bleed itself (the Save PDF dialog's Marks and Bleeds section): `document.exportPdf {marks: {trim, registration, colorBars, pageInfo, kind, weight, offset}, bleed: {useDocument, top, bottom, left, right}}`. Each page's TrimBox is its artboard, its BleedBox the artboard grown by the bleed (the -document's with `useDocument`, set by `document.setup {bleed}`; the art there is kept) and its MediaBox the bleed box -grown to hold the marks (the art is clipped to the bleed box). Trim marks, registration targets and the page +document's with `useDocument`, set by `document.setup {bleed}`; the art there is kept; a PDF/X file with no bleed on a +side where the document's bleed has one is written as asked and comes back with a warning) and its MediaBox the bleed +box grown to hold the marks (the art is clipped to the bleed box). Trim marks, registration targets and the page information (title, artboard, export date in UTC) are drawn in Registration (`/Separation /All`) outside the bleed; the colour bars are process, spot and black-tint patches. Layers whose Print option is off (`layer.setProps {printable: false}`) are left out unless `includeNonPrinting` (or `createLayers`) is on. @@ -3359,6 +3461,20 @@ They list such a command while the user has changed its shortcut (`overridden: t shortcut for that command runs what a click on the item runs: Object › Path › Clean Up…, Print Ink Fixes… and Edit › Find and Replace… open their dialogs. +`shortcuts.exportText {path?}` saves the current set's shortcut list as a UTF-8 text file, as Edit › Keyboard +Shortcuts › Export Text… does with the dialog's set as edited so far. The file starts with a "Keyboard Shortcuts" line +and a "Set:" line with the set's name, followed by a Tools section and a Menu Commands section. Each section lists the +rows Edit › Keyboard Shortcuts lists, in its order and with names as it shows them: one line per row, with the name, a +tab and the shortcuts as the dialog shows them on the current platform. A row with no shortcut ends after the tab. +Without `path` a save dialog asks for the file. A `path` that is not a string, or a file that cannot be written, +returns an error, and nothing is written. The layout is VectorCraft's own. The reference app's [Export keyboard +shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) +page describes saving the list from the Keyboard Shortcuts dialog as a text file, and does not describe its layout. + +```json +{"name":"run_command","arguments":{"command":"shortcuts.exportText","params":{"path":"/Users/me/Shortcuts.txt"}}} +``` + The desktop app adds its active artboard, the one the Artboards panel highlights, to the `object.align`, `object.distribute`, `object.distributeSpacing` and `select.allOnArtboard` calls it runs without `artboard`, as it does for `edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack`, and to each `command.batch`. A batch passes its @@ -3481,7 +3597,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture guide that near. - `moveLockedWithArtboard`: `artboard.move {moveArt: true}`, the Artboard tool and `artboard.rearrange` move locked and hidden art, and art that is also on a locked artboard, with the artboard too, and `artboard.setProps {scaleArt: true}` - scales it too; off (the default) it stays where it is. + scales it too; off (the default) it stays where it is. `artboard.duplicate`, `artboard.copy` and `artboard.cut` copy or + cut locked and hidden art only with it on. A `lockedAndHidden` param on any of these commands takes its place. - `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to the pointer. - `showHandlesMultipleAnchors` (on by default): off, Direct Selection (and the Anchor Point tool, and the Pen with Alt) @@ -3520,7 +3637,11 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features +96 screen points, an inch at the system's reference density whatever the display scaling). The tool shortcut +preference `holdToolKeys` also needs the desktop app. It is on by default: a single-key tool shortcut held for 0.25 s +or more, or held while its tool is used, chooses the tool until the key is let go and then returns to the tool active +before, after any drag in progress; a quicker press chooses the tool for good. With it off, every press and every key +repeat chooses the tool. `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features substituted — ligatures, contextual forms — on screen; Document Setup's Highlight Substituted Glyphs stays separate). `highlightSubstitutedFonts` (on by default: the canvas paints pink behind text drawn in a substitute for a missing font, in every document). `blackOnScreen` @@ -3578,6 +3699,11 @@ Guides change where a snapped point lands: object under the pointer (the gap between their facing edges on an axis on which they lie apart, else the offsets of their two edges on it) or, over no object, to the left, right, top and bottom edges of the artboard under the pointer: measured lines labelled in the General unit. Off, Alt-hovering shows none. +- `snapToLastLocation` (Snap to Last Location, off by default): with Smart Guides on, a selection moved by dragging + (Selection, Group Selection or Direct Selection tool / `pointer_gesture` with `selection`, Shift too) goes back to + where the move began while the move is no longer than `snappingTolerance`, labeled "last location"; farther, it + snaps as before. An Alt copy is not pulled back onto its original, and with View › Snap to Grid or Snap to Pixel on + the move snaps to the grid or to whole pixels instead. A value that is not a boolean is refused. The Snap to Glyph options (the desktop app's Character panel menu › Show Snap to Glyph Options; View › Snap to Glyph is the desktop app's): `view.snapToGlyph.options {baseline?, xHeight?, bounds?, proximity?, angular?, anchors?}` (or @@ -3788,6 +3914,12 @@ first; no spaces), stored back as the object name. `typeSizeIncrement`, `baselineShiftIncrement`) with `measure: "general"|"type"`; they are kept in points and also take a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `Centimeters`, `Pixels`, `Feet & Inches`, `Meters`, `Yards`, `Feet` (the preferences use `points`, `picas`, … `feetInches`, `meters`, `yards`, `feet`). +`edit.preferences {category?}` opens the Preferences dialog (`preferences`) with its search field focused. Its +`__search` field (`ui.dialog.set`) holds the search text: while it has text, the category list is replaced by the +settings whose label, section or category contains it, ignoring case, in the interface language or in English, or by +"No settings found". Choosing a setting sets `__category` to its category, sets `__found` to its key (the row is +highlighted until another category is shown) and clears `__search`. Cmd/Ctrl+F focuses the field again; Escape in the +field with text clears it, and Escape in the empty field closes the dialog. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"unitsGeneral","value":"millimeters"}}} @@ -3865,6 +3997,12 @@ with an `index` it closes the document and all its views. `window.nextDocument` through the views in tab order and add `view` to their reply for a document with more than one view. `ui.inspect` reports `windows` (every window as `[document index, view number]`, in tab order), `currentWindow` and each pane's `view`. Views are not saved. +`window.control.dock {edge}` (desktop app) docks the Control bar to the top of the window, under the menu bar +(`edge: "top"`, the default), or to its bottom (`"bottom"`), and returns `{edge}`. The bar's panel menu runs it with +Dock to Top and Dock to Bottom, the edge in use checked, and so does a drag of the bar's gripper, which docks the bar at +the edge the pointer is released nearer and shows a band along that edge while it moves. The edge is kept with the +workspace, and a workspace saved without it docks the bar at the top. Another `edge`, or none, fails with "`edge` must +be one of top, bottom" and leaves the bar where it is. ```json {"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}} @@ -3935,8 +4073,8 @@ isolation mode. Artboards copy with their art. `artboard.copy {index? | indices?, art?}` puts artboards on the clipboard together with the art fully inside each (`index` and `indices` default to the artboards the Artboard tool has selected; `art` -defaults to the tool's `moveArt` option, Move/Copy Artwork with Artboard; locked and hidden art only with prefs -`moveLockedWithArtboard`); with the Artboard tool chosen, `edit.copy` does this for the tool's selected artboards and +defaults to the tool's `moveArt` option, Move/Copy Artwork with Artboard; locked and hidden art only with +`lockedAndHidden: true`, default: prefs `moveLockedWithArtboard`); with the Artboard tool chosen, `edit.copy` does this for the tool's selected artboards and `edit.cut` runs `artboard.cut`, which also deletes them (one artboard is always left: a cut of every artboard fails). Then `edit.paste` adds a copy of each artboard, together right of the last one and placed as they lay, with their art in the layers it came from, in one undo step (`edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack` put @@ -3949,6 +4087,9 @@ active one) as `indices`, and a row dragged onto New Artboard passes its artboar `artboard.move {copy: true, moveArt: true}` is the Artboard tool's Alt-drag. `artboard.duplicate` and `artboard.move` also move the pattern tiles of the copies when `patterns` is true (default: prefs `transformPatternTiles`); `edit.paste` leaves the tiles of a pasted artboard's art where they were. +`artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard.move` and `artboard.rearrange` take `lockedAndHidden?` +(default: prefs `moveLockedWithArtboard`), and with the Artboard tool chosen `edit.copy` and `edit.cut` pass it on. When +art goes along, the journal records the value, so a replay under another preference takes the same art. Every artboard command that takes an `index` fails with `no such artboard` when the `index` is not the 0-based index of an artboard (past the last one, negative, fractional, a string or null), and the document and the clipboard stay as they were. @@ -3956,8 +4097,8 @@ These artboard commands refuse a string, a number or another value that is not t `` `art` must be true or false ``, naming the key, and leave the document, its undo history and the clipboard as they were: `moveArt`, `scaleArt`, `strokes`, `corners`, `patterns`, `lockedAndHidden` and an artboard's `locked`, `showCenterMark`, `showCrossHairs` and `showVideoSafeAreas` in `artboard.setProps`; `showCenterMark`, `showCrossHairs` -and `showVideoSafeAreas` in `artboard.new`; `moveArt`, `copy` and `patterns` in `artboard.move`; `art` and `patterns` in `artboard.duplicate`; -and `art` in `artboard.copy` and `artboard.cut`. `artboard.setProps` refuses an artboard's `x`, `y`, `width` or +and `showVideoSafeAreas` in `artboard.new`; `moveArt`, `copy`, `patterns` and `lockedAndHidden` in `artboard.move`; `art`, `patterns` and `lockedAndHidden` in `artboard.duplicate`; +and `art` and `lockedAndHidden` in `artboard.copy` and `artboard.cut`. `artboard.setProps` refuses an artboard's `x`, `y`, `width` or `height` that is not a number, and `artboard.move` refuses such a `dx` or `dy`, with `` `dx` must be a number, not "10" ``. A null is read as a key left out. `artboard.new` refuses an `x`, `y`, `width` or `height` that is not a number with the same message, and @@ -4065,7 +4206,7 @@ editable until a selection leaves it out or an edit moves it off the locked artb other art on it. `edit.undo`, `edit.redo` and a failed `command.batch` bring back the selection a step had, with that art selected and editable when it was. When another artboard moves, scales or is cut (`artboard.move`, `artboard.setProps`, `artboard.cut`, `artboard.rearrange`), art that is also on a locked artboard stays where it is -unless prefs `moveLockedWithArtboard` is on (`lockedAndHidden` for `artboard.setProps`); a copy of an artboard copies +unless `lockedAndHidden` is true (default: prefs `moveLockedWithArtboard`); a copy of an artboard copies it. With the Artboard tool, a click selects a locked artboard, but a drag doesn't move or copy it, it shows no handles, Delete leaves it, and a double-click on its name above the canvas doesn't edit the name (`artboard.setProps {name}` and the Artboards panel @@ -4103,6 +4244,16 @@ is the width of one video pixel over its height, which `view.videoRulers` counts the native format keep both (written only when on or not 1), and `inspect_document` adds `showVideoSafeAreas: true` and `videoRulerPixelAspectRatio` to an artboard that has them. +Artboard Options' Global checkboxes are application preferences, not artboard properties: `fadeRegionOutsideArtboard` +(Fade Region Outside Artboard) and `artboardUpdateWhileDragging` (Update While Dragging), both on by default +(`prefs.set {values: {fadeRegionOutsideArtboard: false}}`; also Edit › Preferences › General › Artboard Options). With +the first on, the canvas draws everything outside the artboards darker while the Artboard tool is active, the pasteboard +and the art on it: each color channel at 60% of its value, VectorCraft's own amount. With the second off, that area +draws as usual while the Artboard tool moves or resizes an artboard and darker again when the drag ends. When crossing +artboards would leave more than 4,096 separate rectangles in view, only the area outside the box around all the +artboards draws darker. Presentation Mode, exports and print do not change. The user guide describes both checkboxes under Artboard options +(https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf). + ```json {"name":"run_command","arguments":{"command":"artboard.copy","params":{"index":0}}} {"name":"run_command","arguments":{"command":"edit.paste","params":{}}} @@ -4214,9 +4365,11 @@ is mixed with middle gray by its alpha. A placed image linked to its file (`file.place {path}`, Link on) records the file's absolute path, its size, modification time and hash, and keeps a low-resolution preview (at most 256 px a side): a `.vectorcraft` save writes -the preview instead of the pixels, plus the path relative to the saved file. `document.open` reads +the preview instead of the pixels, plus the path relative to the saved file. Links inside symbols, graph designs, +patterns and opacity masks are saved with their relative paths too. `document.open` reads the linked files again, looking for each at its path, then at its relative path and by name in the document's folder -(so a folder moved with its links still opens). Only files on this computer, or on the network share the document is +(so a folder moved with its links still opens), and each link, in the layers or inside that art, takes the path where +its file was found. Only files on this computer, or on the network share the document is on, are looked at: a path to another computer, a device or a URL counts as missing. On Windows, a document opened from a mapped drive (`Z:\…`) is on the share the drive is mapped to, and links on that share (`\\server\share\…`) are looked at. A link on a drive mapped to another share (`Y:\…`) counts as missing, from a document on this computer too. @@ -4226,7 +4379,19 @@ The result lists `missingLinks` path isn't followed) when it isn't. The desktop app also checks the active document's links when its window comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`, `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at -another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. A PNG, JPEG, +another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. +`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other object in the layers linked to the same file as +one of them; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +`path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, +`path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, +`path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are +errors, and nothing is relinked. Each call is one undo step. The Links panel's Relink… opens the `relink` dialog +(`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, +another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. +`links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed +documents inside symbols, graph designs, patterns and opacity masks that show the same content (by default also for +those that only that art shows); they have no ids, so the results do not list them. Saving a document also reads it +again for the placed documents of it inside that art in the other open documents. A PNG, JPEG, GIF or WebP file whose pixels can't be read is not shown: `links.relink` fails with "the image's pixels can't be read", and `links.update`, or `document.open` when it reads the file, lists it as missing, its images keeping what they showed. Without a file system (the web), linked images show their previews. A link whose path has a colon and no `/` @@ -4743,8 +4908,7 @@ or in `options` (`file.formatOptions {format}` lists them with their values): - The editing copy of a `.ai` or `.ait` file (`editingData`) names its active artboard (`CropAreaActive`): the active artboard of the app's view when the app saves or exports the file, otherwise the artboard the document opened on (the active artboard of the `.ai` file it was opened from), else the first. A `.ai` file read from its editing copy - opens on that artboard. VectorCraft opens a `.ai` file it saved from the native document the file carries, on the - first artboard. + opens on that artboard, and so does a `.ai` file restored from the native document it carries. File → Save As asks for these in the save options dialog (VectorCraft Options) after the save panel, and Save a Copy does too for a `.ai` file; Save, Save a Copy to a native file and Save as Template reuse the options as last saved (or Use Compression). @@ -5052,7 +5216,11 @@ turn it with the object while its path geometry is still baked, so after a rotat stay square to it, as in the reference app. `object.rotate {angle, absolute: true}` turns the selection to an angle (the Transform and Properties panels' Rotate field), and `object.setBounds` measures width, height and the reference point along the turned box (x, y stay page coordinates). Objects turned together share the box. A new group starts -square to the page. `object.resetBoundingBox` squares the box again without moving the art. +square to the page. `object.resetBoundingBox` squares the box again without moving the art. It also squares every +object inside the selected ones: the children of groups, compound paths, clip groups, blends, envelopes, repeats and +intertwines, and the art of linked opacity masks (an unlinked mask's art keeps its angle). Ungroup, Release Clipping +Mask, Release Compound Path, Blend Expand, Release Envelope, Release Repeat, Release Intertwine and Release Opacity Mask +then bring back boxes square to the page. ```json {"name":"run_command","arguments":{"command":"object.rotate","params":{"angle":45,"absolute":true}}} @@ -5361,8 +5529,8 @@ Force) of the selected type (or `ids`): an East Asian comma or full stop ending 、。) hangs outside the frame, Regular only when it doesn't fit, Force always (the rest of the line then fills the measure). Closing brackets and Latin punctuation don't hang; point type, right-to-left and hyphenated lines are left as they are. The hanging mark is as wide as its punctuation spacing makes it (`mojikumi`). New type (the Type tools, -`text.create`) takes `standard`; documents from before it and imported text keep `none`, and `none` isn't saved. One -undo step. +`text.create`) takes `standard`; documents from before it and imported text keep `none`, except legacy `.ai` type +with binding punctuation (`Tg`) on, which opens as `forced`. `none` isn't saved. One undo step. ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"burasagari":"forced"}}} @@ -5798,7 +5966,18 @@ paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and `paraStyle.unused` (Select All Unused in the Character Styles and Paragraph Styles panel menus) list the styles with no uses, in panel order and never the Normal style, and the panel highlights them; `charStyle.delete` and -`paraStyle.delete` take `{names}` to delete several styles in one undo step. A Return the Type tool types (or `text.editRange` +`paraStyle.delete` take `{names}` to delete several styles in one undo step. `charStyle.load` and `paraStyle.load` +(Load Character Styles… and Load Paragraph Styles… in the panel menus, where `ui.loadCharStyles` and +`ui.loadParaStyles` pick the file) copy the styles of that kind from another document VectorCraft opens (`path`, `data` +or `dataBase64`), and `textStyle.loadAll` (Load All Styles…, `ui.loadAllStyles`) copies both kinds, each in one undo +step. The swatches and patterns the styles 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, come along as Paste brings those of pasted type; a global or spot color whose name the document gives another color +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` +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 the story re-flows (a paragraph split between frames has them in both). @@ -5915,7 +6094,18 @@ that no built-in format has. A failing plug-in returns an error and leaves the d no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. -`plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean. `prefs.set` returns an error and +A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) +installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass +the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns +an error naming the permissions needed. The grant is kept with the module's hash; a module +with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A +filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. +A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write +log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log +under its id. A module that imports anything else, or imports these without `api` 2 and `progress`, is refused by +`plugin.install` and `plugin.inspect` with an error naming the import. +`plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean, a `grant` that is not a list of +permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty value or the folder the preference already holds, and the web build does not check folders. @@ -6211,6 +6401,13 @@ envelopes, the path for a top-object envelope); the content keeps the envelope's opacity mask. Type inside an envelope distorts as its glyph outlines, run by run in each run's paint, on the canvas and in every export (SVG, PDF, EPS, EMF/WMF, DXF). +An envelope maps type from the bounds of its glyph outlines, underlines and strikethroughs instead of its laid-out +box (ascent to descent, or an area's frame) when the type is on its own, in groups and layers, or used as a clipping +path. Inline graphics in the type count by their boxes. Rotated type and type in perspective map from the bounds of +their outlines as they are drawn. `object.envelope.makeWithMesh`, `object.envelope.makeWithTopObject` and +`object.envelope.resetWithMesh` with `maintainShape: false` use those bounds. Type that draws nothing keeps its +laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is +VectorCraft's own rule, and envelopes of type in saved files redraw by it. Everything inside bends with the envelope: images as a raster mesh warp (cut into pieces by Fidelity, each a clipped image under its own affine map; SVG shares the pixels between the pieces), symbol instances as their symbol's art, and with Distort Appearance strokes (as their filled outlines) and geometry effects; with it, Distort Linear Gradients @@ -6332,7 +6529,19 @@ shows, and so do the click-to-size dialogs (`perspective.draw` with `at`: the cl `object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to what `object.blend.options` set with no blend selected (`object.blend.info` reads them: `target: "defaults"`), -else Smooth Color and Align to Page. `starts` gives, per object, the anchor of its first subpath the blend starts +else Smooth Color and Align to Page. Smooth Color counts the steps between each pair of keys on its own, from the +keys' colors and visible bounds rather than along the spine. The counts below are VectorCraft's own values. The +colors compared are each key's solid fill and stroke, the fills and strokes of two text objects' runs at the same fraction of their characters, and +those of two groups' members paired in order. When they differ, the count is 255 times the largest change of one +channel as a fraction of its range, a half step rounding down, from 1 to 254. An RGB channel's range is 256 values, +so pure red to pure blue makes 254 steps. CMYK and gray count by their percentages, two tints of the same swatch by +their tints, and colors in different spaces (or Lab) by their RGB values over 255. The count is then at most eight +per point of the largest of the keys' widths and heights and the distance between their centers along either axis, +and when a key has no bounds it is the color count alone. When the colors match, a solid paint meets no paint, or a +key holds a gradient or a pattern, the count is the largest distance between the keys' corresponding visible +bounding-box edges divided by their mean width (left and right edges) or height (top and bottom), rounded, less one, +from 1 to 1000. Keys with no width or no height are measured by their other side, keys with neither in points, and +keys without bounds make 1 step. `starts` gives, per object, the anchor of its first subpath the blend starts 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 diff --git a/docs/plugins.md b/docs/plugins.md index e1fcce813..27a7b0f91 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,13 +23,13 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters)) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. | -| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | -| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so is a heavy filter (nobody was asked). | +| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | Live effects are applied like any effect: `effect.apply {"effect": "plugin.", "params": {…}}`, edited with `effect.setParams`, listed by `effect.list` (menu `Effect › Plug-ins`) and baked by Object › Expand Appearance. @@ -47,7 +47,8 @@ heading and buttons in view. Exporters are formats of **File › Export › Expo Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the -background ([below](#heavy-filters)). +background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed +([below](#permissions)). **Preference folder.** Edit › Preferences › Plug-ins & Scratch Disks › *Additional Plug-ins Folder*: when set, every `*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; @@ -66,7 +67,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Limit | Default | On violation | |---|---|---| -| Host imports | none: no WASI, file system, network, clock or randomness | module rejected at install | +| Host imports | none at API level 1; at level 2 only the three [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import | | Module size | 32 MiB | rejected | | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | @@ -101,6 +102,52 @@ much there is only once the run has ended. - `plugin.run` itself runs a heavy filter where it is called, as it runs any filter: an MCP client waits for it, and its `notifications/cancelled` stops it. +### Permissions + +A manifest may list what the plug-in may do in `permissions`. `doc.read`, `doc.write` and `state` follow the +capability model proposed for PhotoCraft plug-ins +([storytold/photocraft#662](https://github.com/storytold/photocraft/issues/662)); the other names are VectorCraft's own: + +| Permission | Kinds | What it allows | +|---|---|---| +| `doc.read` | all | Read the objects it is given. | +| `doc.write` | filter | Change, add and delete objects in the document. | +| `state` | filter | Keep data of its own with the document (`documentData`). | +| `effect.render` | effect | Change how the objects its effect is applied to are drawn. | +| `export.write` | exporter | Write the document as a file in its own format. | +| `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | + +- A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter + `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and + `export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is + empty may do none of these, and also installs without a prompt. +- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three. + Any kind may add `progress`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply + to the kind, more than 16 names, `progress` with an `api` below 2, or one of the names kept for a later plug-in API + (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. +- **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of + the file and each permission in plain words, and install it once the user clicks OK, if the file still has the + SHA-256 shown; Cancel installs nothing. The + names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. + `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent + passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the + `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. +- The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the + `Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that + lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a + permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or + removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than + 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept + in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, + a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` + install only plug-ins whose permissions were granted, and report the others. +- The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its + `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the + document is refused. A refused output leaves the document as it was. +- `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host + imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. +- Parameter declarations are unchanged by permissions (`params`, below). + Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that @@ -121,8 +168,9 @@ geometry: a plug-in runs again only when one of them changes. ## ABI v1 A plug-in is a core WebAssembly module (MVP + the usual post-MVP features: bulk memory, sign extension, multi-value, -saturating conversions; no SIMD, no memory64) with **no imports** and these exports. Integers are `i32` unless noted; -pointers are offsets into the module's memory. +saturating conversions; no SIMD, no memory64) with these exports and no imports other than the [host +imports](#host-imports-api-level-2) of API level 2. Integers are `i32` unless noted; pointers are offsets into the +module's memory. | Export | Signature | Meaning | |---|---|---| @@ -132,6 +180,34 @@ pointers are offsets into the module's memory. | `vc_alloc` | `(size) -> ptr` | A block of `size` bytes the host may write, or `0` on failure. Never freed: each run gets a fresh instance. | | `vc_run` | `(input, input_len, params, params_len) -> i64` | Runs the plug-in on the input JSON with the parameters JSON; returns `(len << 32) \| ptr` of the output JSON, or a negative error code. | +### Host imports (API level 2) + +A plug-in whose manifest gives `"api": 2` and lists the `progress` [permission](#permissions) may import these +functions from module `env` (the module a Rust or C toolchain gives `extern` functions by default). `vc_abi_version` +still returns `1`. + +| Import | Signature | Meaning | +|---|---|---| +| `vc_progress` | `(done: i64, total: i64)` | `done` of `total` steps are finished; `total` 0 or less when not known. Negative values count as 0, and `done` is clamped to `total`. | +| `vc_cancelled` | `() -> i32` | `1` once the user cancelled the run or its time budget ran out, else `0`. | +| `vc_log` | `(level, ptr, len) -> i32` | Writes the UTF-8 text at `ptr` as a log line of `level` (0 error, 1 warning, 2 information, 3 debug). Returns `0` when written, `1` when the run's log budget is spent and the line was dropped, `-1` when the text doesn't lie inside the module's memory, `-2` when it is not UTF-8, `-3` for another level. | + +- The host checks the imports when the module is installed: an import it doesn't offer, one with another signature, + and one the manifest doesn't allow (an `api` below 2, or no `progress` permission) refuse the module, and the error + names the import. +- A filter's or an exporter's progress is shown as a share: a heavy filter's progress window and the export's progress + window draw a bar with a percentage, and Cancel there makes `vc_cancelled` return `1`. A plug-in that checks it can + stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that + ignores it is still stopped by the instruction and time budgets. With a known total, a report is passed on only when + it reaches another tenth of a percent; with an unknown total, each change of `done` is passed on. After 2,000 reports + in a run, one change in 1,000 is passed on, and so is the first report after the total becomes known or unknown. A + live effect's progress is not shown, since it is drawn as part of something else. +- Log lines go to the app's log (target `vectorcraft_plugins`) as `: `, with control and + bidirectional characters escaped. The whole text must be UTF-8. A line shows its first 1,024 bytes, as at most 1,024 + characters with the escapes, and ends with `…` when it is cut. A run writes at most 200 lines and 32 KiB of shown + text, and the host reads at most 1 MiB of log text in a run; past any of these, one "log truncated" line is written + and the rest are dropped. + ### Manifest ```json @@ -159,6 +235,8 @@ pointers are offsets into the module's memory. the kind when the plug-in is installed. - `budget` (filters only): `"light"` (the default) or `"heavy"`, for work that takes minutes ([heavy filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. +- `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names + ([permissions](#permissions)); without it, what its kind implies. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. @@ -325,12 +403,12 @@ crates/plugins/example/build.sh # cargo build --release --target wasm32-unk The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source. The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, -WAT…), as long as the module has no imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never +WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, and `wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows in a -live effect's geometry. +live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host functions. ## Performance @@ -340,9 +418,8 @@ per change of their input, parameters or paints (results are cached), not per fr ## Not in v1 -- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host - callbacks (a plug-in reporting progress or asking whether it was cancelled), importers (plug-in formats that open - files). +- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, importers + (plug-in formats that open files). - Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or symbols. - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/roadmap.md b/docs/roadmap.md index c4246c711..d01c002ae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,7 +89,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping and type as text, with live effects left out and symbols and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects kept only on 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 ([file-format-parity.md](file-format-parity.md)), other objects' effects other than geometry effects left out, and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | Hindi interface follow-up (2026-11-06): the catalog and zero/one plural rule are implemented; native wording review, layout checks and a web Devanagari font remain. [Notes](localization-hi.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5793187c..62cb02693 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,8 @@ 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), a saved `.ai`'s editing copy without live effects and with symbols and 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 | 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. | +| 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 | 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 comes from VectorCraft's font metrics, 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. | +| 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 | 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. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | @@ -282,8 +283,9 @@ The discounts, with the evidence: ones are about structure, not missing art: layers flattened or multiplied (#951: 109 layers instead of 14; #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look (#637). VectorCraft's PDF-compatible `.ai` carries an editing copy of the art (layers, groups, paths, colors, - gradients, patterns, images, transparency, opacity masks, clipping and type as text), with live effects left out - and symbols and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one + gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only + on 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, other objects' effects other than geometry + effects left out, and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one artboard per file, no transparency) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. - **Interaction fidelity (×0.9).** Pen, Direct Selection, smart guides and snapping are deep @@ -517,6 +519,7 @@ since 2026-10-01. ## Evidence: what ships today, by area - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. +- Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. @@ -545,6 +548,7 @@ since 2026-10-01. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. +- **Control bar docks to the top or the bottom:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked, and dragging the bar's gripper docks it at the top or the bottom edge of the window, whichever the pointer is released nearer, with a band along that edge while it moves (`window.control.dock {edge}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Keeping the edge with the workspace, with older workspaces docking the bar at the top, is VectorCraft's own rule. Not done: dragging the gripper away from both edges to float the bar. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `the_control_bar_dock_command_takes_top_or_bottom` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` in `crates/ui-egui/src/workspaces.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. @@ -558,6 +562,7 @@ since 2026-10-01. - **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). - **Delete Empty Artboards:** the Artboards panel menu item ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html)) runs `artboard.deleteEmpty`: every unlocked artboard with no artwork goes with its guides in one undo step, artboard 1 stays when every artboard is empty and none is locked, and the panel makes an artboard that is left active; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). Engine tests in `tests/panelcmds.rs` (`delete_empty_artboards_*`) and the panel test `delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left`. The menu item's enabled check measures nothing in a document with one artboard, and Save Gradient Preset's check reads the proxy's paint without measuring the object; both give the same answer as before. An artboard that no path's control points, grown by the farthest its strokes reach, can meet is found empty without measuring the path. With an open path of 200,000 anchors selected, the checks take under 0.01 ms instead of 21.9 ms and 17.2 ms in a release build; with a second, empty artboard, Delete Empty Artboards' check takes 0.85 ms instead of 42.1 ms. Tests `delete_empty_artboards_is_enabled_exactly_when_it_deletes_something`, `delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents`, `empty_artboard_checks_match_the_visual_bounds_on_varied_strokes`, `the_empty_artboard_check_matches_measuring_every_artboard`, `the_save_check_matches_saving_with_and_without_a_gradient`, `the_unmeasured_proxy_paint_is_the_measured_one` and `the_unmeasured_proxy_paint_is_the_measured_one_on_varied_objects`. + Delete Empty Artboards' enabled check measures each version of a document once, so the panel menu does not measure the document again on every frame while it is open. With an open path of 200,000 anchors across two artboards, the check takes under 0.001 ms on a later frame instead of 70.4 ms in a release build, and with 20,000 objects on two artboards it takes under 0.001 ms instead of 9.1 ms. Tests `delete_empty_artboards_check_measures_a_document_once`, `delete_empty_artboards_check_follows_each_change_to_the_document`, `delete_empty_artboards_check_follows_a_change_in_place`, `delete_empty_artboards_check_follows_a_document_switch` and `delete_empty_artboards_check_matches_measuring_after_varied_changes`. The Eyedropper, the Live Paint check for paint to sample, the Appearance panel's Characters chips and the colors the Libraries panel adds from the selection also read the proxy's paint without measuring the object; the Eyedropper measures it only to sample a gradient or a pattern, and the Libraries panel only for a gradient. The colors and paints are the same as before. With an open path of 200,000 anchors, the Live Paint check and the Eyedropper's sample for a point inside it take 0.004 ms together instead of 26.0 ms in a release build. Tests `samples_give_the_measured_proxy_paints_colors` and `colors_and_gradients_come_from_compound_paths_groups_and_colorized_objects`. - **Artboard history selection:** headless pointer/key tests in `panels/properties.rs` pass for panel New → Undo → Delete/Backspace/Properties, Duplicate → Undo → Delete, pruning removed indices and preserving valid multiselection. They assert board survivors, artwork and object selection, Move/Scale Artwork options, saved state and Undo/Redo. Full workspace CI passes. - **Artboard tab-switch follow-up:** changing the active document through its tab aligns the Artboard tool with the displayed board before Properties Delete; clicking the already-active tab preserves multiselection and Move/Scale Artwork options. Three headless `chrome::tests::artboard_document_tab_*` regressions cover actual tab A → B → A clicks followed by Properties Delete, surviving boards and guides, preserved artwork/symbols and Undo/Redo; controls cover same-tab multiselection, Move/Scale Artwork options and other tools. Historical focused validation on `dbd2497b` recorded one expected RED failure with two passing controls, then all three GREEN tests passed. All three also passed in historical full CI on `a0671777`. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. @@ -590,6 +595,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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). - **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`). +- **Fade Region Outside Artboard:** Artboard Options' Global checkboxes Fade Region Outside Artboard and Update While Dragging ([user guide, Artboard options](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)) are application preferences (`fadeRegionOutsideArtboard`, `artboardUpdateWhileDragging`), both on by default. While the Artboard tool is active the canvas draws everything outside the artboards, the pasteboard and the art on it, with each color channel at 60% of its value (VectorCraft's own amount), or only outside the box around all the artboards when crossing artboards would leave more than 4,096 rectangles in view; with Update While Dragging off it draws as usual during an artboard move or resize. Presentation Mode, exports and print do not change. Regression tests: `canvas::tests` (`the_artboard_tool_fades_the_pasteboard`, `presentation_mode_draws_no_fade`, `the_fade_darkens_art_on_the_pasteboard_and_not_on_the_artboard`, `outside_rects_cover_the_bounds_minus_the_boards`, `outside_rects_of_a_row_of_boards_stay_few`, `the_fade_of_crossing_boards_stays_bounded`, `update_while_dragging_keeps_or_drops_the_fade_during_an_artboard_drag`, `the_pasteboard_fade_leaves_exports_unchanged`), `dialogs/tests.rs` (`artboard_options_global_checkboxes_set_the_preferences`, `artboard_options_error_leaves_the_global_preferences_unchanged`). - **Saved views (G7):** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): up to 25 views per document, saved with it, renamed or deleted in Edit Views, chosen from the bottom of the View menu). Each view keeps the zoom, centre and rotation; the View menu lists all 25 (`view.goto1`–`view.goto25`), and Edit Views… lists them to rename or delete, applied together as one undo step. A file's views are checked when it opens (at most 25, unique names up to 255 characters, a finite centre and a positive zoom). Tests: `saved_views_round_trip_and_from_a_file_are_tidied` (`crates/format/tests/prop_format.rs`), `crates/engine/src/cmd/views.rs`, `crates/ui-egui/src/dialogs/edit_views.rs` and `every_saved_view_is_listed_at_the_bottom_of_the_view_menu` (`crates/ui-egui/src/menus.rs`). - **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. - **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. @@ -597,6 +603,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`. - **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. +- **Preferences search:** a Search Preferences field above the category list has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back; typing lists the settings it finds, and choosing one shows its category, as the [help page](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html) describes. VectorCraft's own rules: the field matches a setting's label, section or category, ignoring case, in the interface language or in English; the list replaces the categories while the field has text and shows every match, or "No settings found"; the chosen setting's row is highlighted and scrolled into view; Escape in the field with text clears it, and Escape in the empty field closes the dialog. The dialog's `__search` field (`ui.dialog.set`) holds the text. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. @@ -631,6 +638,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current. - Actions panel: expand an action to show its recorded steps, then clear a step's checkmark to keep it in the action but skip it during playback. The action and set checkmarks include or exclude all their steps; a mixed checkmark includes all. Alt/Option-clicking a step checkmark keeps that step as it was and applies the opposite inclusion state to the other steps in its action. This follows the public [Exclude commands from an action](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) guide; the scope of the mixed-state and Alt-click behavior is VectorCraft's design. The exclusion survives UI restart, action duplication, and `.vcactions` save/load. Existing version 1 action set files load with every step included. - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. + - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. @@ -649,10 +657,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JSON-lines control channel with real egui pointer and keyboard injection. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. + - 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 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. 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. + - `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`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.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. @@ -674,6 +684,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`). - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. + - A floating panel's OS window hides while a tool group's flyout or the All Tools drawer is open in the main window, as it does under a modal dialog or a menu, and shows again when the flyout or the drawer closes (#5813; `a_tool_flyout_hides_the_palette_windows_until_it_closes`). + - On the desktop a floating panel in an OS window stores the monitor it is on and reopens there at the next launch. When that monitor is no longer connected, the panel opens on the main window's monitor at the same offset (#5582). Outside macOS, a desktop whose monitors have different scale factors keeps opening floating panels on the main window's monitor. Test: `panel_docking::native_tests::a_floating_panel_reopens_on_the_monitor_where_it_was_left`. + - A menu in a floating panel's OS window stops at that window's bottom edge and scrolls past it, and is never taller than the window (#5347; `a_long_panel_menu_scrolls_inside_a_short_native_window`). A panel's long submenus (Digits, Stylistic Sets, Save Selection, Move to Group), the libraries menu, the Color Guide's Limit to Library menu and the Layers panel's Filter popup scroll the same way. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). @@ -686,9 +699,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The status bar's messages for documents opened or saved with notes and for discarded or restored recovered documents, and the Global Edit options' Results line, do the same (`open_save_and_recovery_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`); the warnings for linked images and placed documents output as their previews and the errors for a page past a placed file's last page or artboard name their counts in the singular or the plural in English. - Edit › Undo and Edit › Redo, in the in-window and native menus, name the step in the interface language as the History panel lists it (`panels::history::step_name`), so when the catalog has no plain row for a step named after an effect, such as Feather, the menu shows the translation of the effect's menu row without its ellipsis (`undo_and_redo_name_the_step_as_the_history_panel_does`). - The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. - - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. Not done: a new document from the selected state. + - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. + - The New Document from State button at the bottom of the History panel (`edit.newDocumentFromState`) makes a new document, which becomes active, from the state the panel has selected ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/undo-edits-and-manage-design-history.html)). VectorCraft's own rules: the new document takes the next Untitled-N name, is marked unsaved, has nothing selected and has no steps to undo or redo, and the source document and its steps stay as they are. - The Transform panel menu's Transform Object Only, Transform Pattern Only and Transform Both (`transformPanelPatterns`) choose whether the fields, flips and menu flips of the Transform panel and the Properties panel's Transform section move the object and leave its pattern tiles, move the tiles alone, or both (until a choice they follow General › Transform Pattern Tiles, as before); Use Registration Point for Symbol (`useRegistrationPointForSymbol`) measures and transforms one selected symbol instance from its symbol's registration point, where the instance's flips, rotations and shears put it ([Transform panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/transform-panel-overview.html); `panels::transform::tests::the_menu_chooses_whether_the_object_its_pattern_or_both_transform`, `use_registration_point_for_symbol_measures_an_instance_from_its_registration_point`, `use_registration_point_for_symbol_follows_a_transformed_instance`, `tests_prefs::transform_panel_patterns_defaults_to_following_the_general_preference`). + - 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 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`). @@ -705,6 +720,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. - **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`. + - **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_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. + - **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_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) 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. 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`. - 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), 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. @@ -721,7 +740,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except User Interface › Open Documents As Tabs, Units › East Asian Type, Hyphenation › Default Language, Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only; [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - With Performance › Real-time Drawing and Editing off, the bounding box moves with a Selection-tool move drag and the object moves when the button is released ([help](https://web.archive.org/web/20220324053041id_/https://helpx.adobe.com/illustrator/using/real-time-editing.html); `real_time_drawing_off_moves_the_bounding_box_with_a_move_drag`). - Performance › Real-time Drawing and Editing off defers a drag's edits to its release, but a ruler guide dragged out of a ruler or moved still shows as it is dragged, where only its position label showed (#4646; `a_guide_dragged_out_of_a_ruler_shows_while_dragged`, `real_time_drawing_off_still_shows_a_dragged_guide`). - - With Real-time Drawing and Editing off, a dragged selection whose outlines have more than 100,000 points shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`). + - With Real-time Drawing and Editing off, a dragged selection whose outline points and evaluated objects come to more than 100,000 shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects`). + - With Real-time Drawing and Editing off, a move or handle drag of a group, blend, envelope, repeat or intertwine draws the outline of each of its objects instead of one union of them, as VectorCraft's own rule (`real_time_drawing_off_outlines_a_dragged_group_member_by_member`, `real_time_drawing_off_outlines_a_resized_group_member_by_member`, `real_time_drawing_off_outlines_a_dragged_blend_step_by_step`, `real_time_drawing_off_outlines_a_dragged_envelope_object_by_object`). A group with a Pathfinder effect draws the area the effect makes, a gradient mesh draws its mesh lines, and a symbol instance draws its bounds (`real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area`, `real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once`, `real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds`). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). @@ -730,10 +750,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Pattern Options panel's Name field and the name fields that a double-click opens in the Layers, Artboards and Asset Export panels take Shift+Enter (Shift+Return) the same way: it applies the name and keeps the field active with the name selected. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels, and so do the links that label a field (the Control bar's Stroke: and Opacity:, the Properties panel's Stroke), which a click still follows to their panel (#4088). Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. - In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) applies a value to a copy of the selection and selects the copy, in one undo step. With anchor points selected with Direct Selection it applies the value to them, as Enter does. Ctrl/Cmd+Enter in W or H applies the value and scales the selection proportionally with the link off. With the Artboard tool, it scales the active artboard proportionally. A percentage typed alone in W or H (`50%`) is that share of the size the field shows, and the link and Ctrl/Cmd+Enter keep the proportions as with a length (#2401). - - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. + - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. As VectorCraft's own keyboard rule, the locator is one tab stop in the Control bar popover, the Transform panel, the Properties panel and the dialogs, the Arrange Tools dialogs included. While it has the focus it shows an outline, and ←, →, ↑ and ↓ move the reference point one square, stopping at the edges (`the_reference_point_grid_is_one_tab_stop`, `arrow_keys_move_the_reference_point`, `the_reference_point_locator_is_one_tab_stop_and_arrows_move_it`). - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. - While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does. - - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). + - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. @@ -757,6 +777,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Cmd/Ctrl+F6 (`window.nextDocument`) and Cmd/Ctrl+Shift+F6 (`window.previousDocument`) make the next and the previous open document the current one, in tab order and as a click on its tab does, 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 navigating to the next and the previous document. After the last document comes the first, and before the first document comes the last. From the Home screen the keys go to the first and the last document. Keyboard Shortcuts lists both under Window. - Cmd/Ctrl+Alt+Shift+U (`document.switchUnits`) switches the document to the next unit of measurement, in one undo step, 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 switching units of measurement. The page does not give an order. VectorCraft steps through the units in the order a right-click on a ruler lists them (Points, Picas, Inches, Millimeters, Centimeters, Pixels, Feet & Inches, Meters, Yards, Feet) and goes from Feet back to Points. - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). + - Edit › Keyboard Shortcuts › Export Text… saves the set shown in the dialog as a UTF-8 text file, as the reference app's [Export keyboard shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) page describes (`shortcuts.exportText {path?}`). The layout is VectorCraft's own: the set's name, then the Tools and Menu Commands rows in the dialog's order, each with its name, a tab and its shortcuts as the dialog shows them, and nothing after the tab for a row with no shortcut. Tests `shortcut_editor::tests::export_text_*` and `tests::picks::keyboard_shortcuts_export_text_writes_the_working_set_as_text`. - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559). @@ -784,6 +805,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Edit › Keyboard Shortcuts lists each of the Align panel's Align Objects, Distribute Objects and Distribute Spacing buttons under Align, with no default shortcut, as the reference app's [release notes](https://web.archive.org/web/20261006170350/https://helpx.adobe.com/illustrator/desktop/new-features/release-notes.html) describe for its Align and Distribute commands. A shortcut set there runs what a click on the button runs, with the Align To choice, the key object and the Distribute Spacing value (`align.horizontalLeft` and the other `align.*` UI commands). The command palette lists `select.key` as Cancel Key Object, and a pick cancels the key object (#541). Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). + Commands that act on `id` or else the selection, the highlighted layer row or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseToLayers`, the graphic, character and paragraph style commands, `image.info`, the `links.*` and `shaper.*` commands that take an `id`, opacity mask editing and viewing, `symbol.edit`, the blend spine commands and `path.curvature`) refuse an `id` that is not an object id (`"abc"`, `"5"`, `true`) and change nothing; `effect.apply` with the effect given as `id` still applies it to the selection. Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged. The Select menu's Same commands, its Object commands that select art, Next Object Above and Below, saved selections and the Magic Wand (#2655) select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. Select ▸ Object ▸ Bristle Brush Strokes selects the objects whose topmost stroke has a bristle brush, whatever the brush is named, and leaves out objects whose brush is of another type, also when its name has "Bristle" in it. @@ -840,20 +862,21 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the opacity set on a mark's top fill or stroke row, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the paint, weight and opacity of a mark's top visible fill or stroke row after an Appearance command or the Eyedropper, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. While a drag rotates the view, a dial at the center of the canvas shows the angle. - **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation). While a Type tool edits type, a click that lands on no type ends the editing without making new type, and that tool stays active; the next click makes new type (#2871, #2854). + As VectorCraft's own rule, Cmd+Enter (Ctrl+Enter on Windows and Linux) finishes editing as Escape does and keeps the typed text; a command bound to that chord runs instead. Inside a ligature (one glyph for several characters, such as fi), the Type tool's caret, clicks, arrow keys and selection stop between its characters, at the font's ligature carets ([OpenType GDEF ligature caret list](https://learn.microsoft.com/en-us/typography/opentype/spec/gdef)) or, when the font does not list one valid caret for each stop, at the glyph's width divided equally. Contour-point carets use the font's unhinted outline at its default variation location; pixel-size device adjustments are not applied. Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides. - **Touch Type:** a click with the Touch Type tool (Shift+T) selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. Each drag is one undo step that sets the character's kerning, baseline shift, scales or rotation, and the type stays editable (`text.touchType`; behavior reference: [Touch Type tool](https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html), [Add text with the Type, Area Type, and Touch Type tools](https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html)). Composition's leading-space reduction stays along the line when a character rotates. An opening bracket trimmed by Line-end Punctuation Half Width Mojikumi keeps that placement under Character Rotation; its turning point follows the composition adjustment, and removing the rotation keeps the trim. Scores and estimates are unchanged. A character rotated near or at a quarter turn follows a Touch Type drag across its line as well as along it: baseline shift moves a character up from its line at any rotation, and the character turns about its shifted pen position. Right-to-left characters can also move along their line when a preceding logical character is on that line. Positive `text.touchType` `dx` follows reading order, toward the left in horizontal right-to-left text; the first logical character still moves only up and down. These direction rules are VectorCraft's choices; the public Touch Type page describes moving a character without specifying this case. Scores and estimates are unchanged. - - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. + - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. A single-key tool shortcut held for 0.25 s or more, or held while its tool is used, chooses that tool only until the key is let go, and the tool active before comes back (after a drag in progress ends); a quicker press chooses the tool for good. This is VectorCraft's own rule and its own threshold, and General › Hold a Tool Shortcut to Use the Tool Temporarily (`holdToolKeys`, on by default) turns it off. Space held while the Zoom tool drags a marquee moves the marquee and keeps its size ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). When Space is let go, the drag sizes the marquee again from where it moved to. A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color. @@ -898,6 +921,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Panel Options › Locate Selected Objects (beyond Illustrator, off by default): when the last selected object changes on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as Locate Object does, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off. - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges is nearest a gridline on each axis (the center does not snap), #740, #1816; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). A move that Shift keeps level, upright or diagonal stays on that line when it snaps, with the Selection, Group Selection or Direct Selection tool. It slides along the line until the selection lands on the grid or on whole pixels, lines up with the other objects or is spaced evenly with them, or until the line passes an anchor or crosses a ruler guide near the point the selection was grabbed by. A level or upright move snaps along its own axis only. + Snap to Last Location: with Smart Guides on and Preferences › Smart Guides › Snap to Last Location on (off by default, VectorCraft's own choice), a selection moved by dragging with the Selection, Group Selection or Direct Selection tool, Shift too, goes back to where the move began while it is within Snapping Tolerance of it, labeled "last location"; an Alt copy is not pulled back onto its original, and with View › Snap to Grid or Snap to Pixel on the move snaps to the grid or to whole pixels instead (`prefs.set` key `snapToLastLocation`). The public [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) page lists the option as snapping objects back to their previous position; the distance, the label, the copy rule and the grid and pixel rule are VectorCraft's own. Tests: `select::tests::snap_to_last_location_puts_a_move_back_where_it_began`, `select::tests::snap_to_pixel_wins_over_snap_to_last_location`, `direct::tests::snap_to_last_location_puts_a_path_moved_by_its_anchor_back`, `direct::tests::snap_to_pixel_wins_over_snap_to_last_location_for_a_path_moved_by_its_anchor`, `tests_prefs::snap_to_last_location_puts_a_dragged_square_back`. Distance Guides: with Smart Guides on (View › Smart Guides and Preferences › Smart Guides › Invoke Distance Guides, on by default), something selected and the Selection tool, holding Alt/Option over another object shows the distances between the selection's bounds and that object's: on an axis on which they lie apart, one line across the gap between their facing edges; on one on which they overlap, the offsets of their left and right (top and bottom) edges, each line running across the middle of where they overlap, with a dashed line out to the other object's edge when it passes beside it. Over an empty part of an artboard it shows the selection's distances from the artboard's left, right, top and bottom edges. Each line has ticks at its ends and its length in a pill of the smart guide colour, in the General unit; letting Alt go, pressing (a pan or zoom press too), moving off them, leaving the canvas or a floating panel, menu or dialog over the pointer takes them away, and with the pointer still they follow what lies under it as the view scrolls, zooms or turns, as the document, the tab or the selection changes, and when the Selection tool is switched to. Behaviour from the public [Use Distance Guides for accurate placement](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html) and [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) pages, which describe hovering only: nothing shows while moving or nudging. Tests: `select::tests::alt_over_*`, `select::tests::distance_guides_need_alt_a_selection_and_smart_guides`, `tests_prefs::invoke_distance_guides_measures_the_selection_to_the_object_under_alt`, `canvas::tests::alt_over_another_object_draws_the_distance_guide_label`, `canvas::tests::distance_guides_go_when_the_hover_ends`, `canvas::tests::distance_guides_follow_what_lies_under_a_still_pointer`. A move pressed on a path's stroke, or within 6 px of its outline away from its anchors and center, is held by the nearest point of that path, with the Selection, Group Selection or Direct Selection tool, and a straight segment Direct Selection drags is held by its own nearest point. That point is what snaps, so the path lands exactly on a ruler guide (with "align") instead of the pressed spot landing there and the path a few pixels beside it, an offset that showed only zoomed far in (#3701). A press in the middle of a fill still snaps the pressed spot: the public [Align objects with guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) page has Snap to Point align the point under the pointer, which is placed exactly where the object should meet the guide. A bounding-box handle dragged with the Selection or Free Transform tool snaps what it moves as a dragged point snaps (#2054). With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. With Smart Guides off and Snap to Point on, it snaps to the other objects' anchors and the ruler guides within the Snap to Point distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as the other objects. Shift still keeps the proportions, and Alt still scales about the center. @@ -942,6 +966,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Geometry and effects:** - **Open-path anchor Delete:** Delete/Backspace removes selected anchors and their adjoining segments, leaving drawable runs as separate open subpaths of the original object. Surviving curves and appearance stay intact; one Undo restores the document and selection, and a second Delete removes the selected remainder. Explicit Remove Anchor Points still reconnects/refits. Four core regressions, one full-frame Delete/Backspace regression and three existing removal/segment controls pass, covering these distinctions, multiple deletions, transformed geometry and mixed whole-object selection. Behaviour references: the legacy public [Illustrator reference guide, Add or delete an anchor point](https://helpx.adobe.com/content/dam/help/en/pdf/illustrator_reference.pdf) distinguishes keyboard deletion from dedicated removal; the current [Add or remove anchor points](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/add-or-remove-anchor-points.html) describes the dedicated tools. Other G11 gaps and scores/hours remain unchanged. - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). + Outline Stroke and Unite remove the overlaps of a long, low path with the sweep turned along it: when the estimated number of segments the sweep holds at once is under two thirds as large for the path turned a quarter turn (paths of 2,048 segments or more), the overlaps are removed on the turned path and the result is turned back, which moves no point by rounding. The lookups of the input curve that a piece of the result lies on, and of the input anchors that are kept, can search along y instead of x: once lookups with more than 64 candidates along x have counted 16 for each input curve or anchor, a lookup searches along y when it finds fewer candidates there (under a quarter as many for curves), and where that has rarely happened it measures along y in only one lookup of 32. On one open path of 200,000 anchors in rows of 1,000, Outline Stroke takes 17.3 s of processor time instead of 31.3 s and Unite 2.9 s instead of 6.5 s in a release build. A turned result has as many subpaths as the result without the turn, and the area where the two differ is no larger than the refit precision times the outline's length (`crates/pathops/src/sweep_axis.rs` tests). - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged. - Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect. - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). @@ -964,6 +989,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Public references: [Simplify benefits](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-path-benefits.html) and [advanced options](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-paths-advanced-options-overview.html). Nearly unchanged appearance is expected when reducing points. The checklist is partial, without raising scores. Simplify and Add Anchor Points record no undo step when they change no path, and a path they leave as it is keeps its live shape. + Simplify keeps its result within the tolerance of the path, on long arcs and on paths of many anchors alike (`fit.rs` tests `no_fitted_piece_is_farther_than_the_tolerance` and `long_arcs_stay_within_the_tolerance`). A closed subpath that refits to one curve is dropped, so this does not hold for it. A segment that would need more than 65,536 samples at that tolerance stays as it is, and with Convert to Straight Lines it becomes evenly spaced lines instead, up to 65,536, which past the limits in `docs/mcp.md` (Simplify) can be farther than the tolerance from the segment. The Simplify dialog also has Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path. The threshold is a slider from 0° to 180° and opens at 150°: an anchor whose corner is sharper than the threshold stays a corner, so moving the slider right keeps more corners, and the dialog sends `cornerAngle` as 180° less the threshold. While Preview is on, Show Original Path draws the paths as they were before the preview in a wide, faint band of their layer's color under the selection's outlines. Object › Path › Simplify opens a slider under the selection ([Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html), [Auto simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/auto-simplify-paths.html)) with Auto-Simplify, a rail from the fewest anchor points to the closest fit, and More Options, which opens the dialog at the slider's tolerance. The rail's levels are VectorCraft's own scale (`object.path.simplify {level}`): level 50 is 1 pt, and every 10 levels halve or double the tolerance, from 32 pt at 0 to 1/32 pt at 100. Auto-Simplify picks the lowest level whose tolerance is at most 1/400 of the selection's longer side, or level 100 when that side is under 12.5 pt; the slider opens at that level, with its result previewed, as the manual-simplify page describes. The dialog's "Open this dialog with these settings next time" makes the menu item open the dialog on the settings of its last OK. With anchors or segments of a path selected with Direct Selection, Simplify acts on just that part of the path, as the [Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) page puts it: the slider, Auto-Simplify and the dialog refit only each run of selected segments, between its end anchors, which keep their place and their handles on the side away from the run. The anchors and segments outside the runs stay exactly as they were, a closed path's run may cross its start point, separate runs on one path are refitted independently in one undo step, and the runs stay selected. A path with only lone anchors selected is still simplified whole, and `object.path.simplify {wholePaths: true}` simplifies partly selected paths whole. Tests: `crates/engine/src/tests/simplify_part.rs` and `simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path`. @@ -1002,6 +1028,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Select › Same › Fill Color, Stroke Color, Fill & Stroke, Stroke Weight, Opacity, Blending Mode, Appearance, Shape, Graphic Style, Appearance Attribute and Symbol Instance compare the other objects with the first selected object that can be selected, of any kind: an object that is hidden or locked, on a template layer, on a locked artboard while nothing is isolated (except new art while it stays selected), or outside the isolated group is passed over. This is VectorCraft's own behavior. Select › Object › All on Same Layers takes the layers only of the selected objects that can be selected: an object that is hidden or locked, on a template layer, on a locked artboard while nothing is isolated, or outside the isolated group adds no layer. This is VectorCraft's own behavior. Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold. + Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. 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. @@ -1026,6 +1053,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). + Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. @@ -1048,6 +1076,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Preview:** the Image Trace panel's Preview checkbox, before Trace, is on by default, and a setting change traces the selected Image Trace object again at once. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected. When Preview is turned on with that object not selected, the object is traced with its changes when it is selected again. Preview is kept with the UI preferences, and Reset to Default leaves it. The `imageTrace.preview` command turns it on or off. - **Image Trace small contours:** closed fits too small to form an editable subpath retain their positioned outline, including holes. Source-generated island, hole/winding and stepped-region regressions cover Corners and Noise; defaults, parity scores and estimates are unchanged. - **Image Trace, custom presets:** Manage Presets next to the Image Trace panel's Preset dropdown saves the panel's settings as a new preset under a name (Save as New Preset…) and renames or deletes the saved preset shown; built-in presets don't change. Saved presets are kept with the preferences and follow the built-in ones in the panel, Control bar and Properties preset lists. `imageTrace.presets.save` and `imageTrace.presets.delete` save, rename and delete them, `imageTrace.presets` lists them, and `imageTrace.make {preset}` traces with one by name. +- **Image Trace, preset buttons:** six buttons above the Image Trace panel's Preset dropdown choose a preset as the dropdown does, for the options the [help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-panel-options.html) lists before Preset. The presets each button chooses are VectorCraft's own: Auto Color chooses Auto Color, High Color chooses High Fidelity Photo, Low Color chooses Low Fidelity Photo, Grayscale chooses Shades of Gray, Black and White chooses Black and White Logo, and Outline chooses Outline. With Preview on, a click traces the selected Image Trace object again as one undo step; with nothing traced selected, it changes the panel's settings only. The button of the panel's preset shows as selected. - **Create Strokes baseline (2026-10-17, measured):** the ignored centre-lines class covers 23 exact code-drawn scenes in aa and hard variants, including junctions, crossings, fills meeting strokes and varying widths. [Baseline and metric definitions](image-trace-quality.md#centre-lines). Topology is recovered from emitted geometry; width-profile metrics remain unavailable. No behaviour change or score increase. - **Image Trace, collapsed loops keep few segments:** a loop whose fit collapses to one cubic keeps the fewest segments that hold its area and course, so a higher Paths gives no fewer segments; a code-drawn Paths sweep in `crates/trace` (see [quality notes](image-trace-quality.md#collapsed-loops-keep-few-segments)). - **Image Trace, stacked layers only under their own areas:** with Method Overlapping, a layer reaches under the colors above it only within the connected areas that hold its own color, so a shape that sits apart from a color is no longer written again, at the same bounds, in that color, and the edge of that color's layer no longer shows through that shape's anti-aliased rim. *Measured* by `crates/trace/tests/quality` on each preset's own inputs: High Fidelity Photo 121 paths to 69.6 with ΔE 1.74 to 1.72, Low Fidelity Photo 37.1 paths to 26.1. Code-drawn disc, square and line tests in `crates/trace`. @@ -1063,6 +1092,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. + Smooth Color counts the steps of each pair of keys from their color change, or from the distance between their visible bounding-box edges when their colors match or they hold gradients or patterns ([Blend Options](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)); the step counts, their caps and the colors compared are VectorCraft's own. Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)). The four polynomial distributions are original approximations of the public description, not measured numeric parity. See [Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). @@ -1089,6 +1119,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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. 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. The prompt for deleting several swatches and groups uses the interface language's plural form for the count. Evidence: `deleting_several_swatches_uses_each_count_form` in `crates/ui-egui/src/panels/swatches/tests.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. EPS and SVG write the image's own pixels. @@ -1096,6 +1127,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). + Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them. View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. The Separations Preview panel's Gamut Check button reports one color outside the CMYK gamut in the singular and more in the plural, in English and in each language's plural forms (`gamut_check_status_takes_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). @@ -1108,6 +1140,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Recolor Artwork Edit displays:** buttons beside the Edit tab's wheel show the colors on a smooth color wheel, a segmented color wheel or as color bars ([Edit colors using Recolor Artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/edit-colors-using-edit-colors-dialog.html)). The segmented wheel draws 24 hues by 5 saturations as solid patches, and its markers move freely as on the smooth wheel. The bars show one solid bar per recolored color in row order: a click selects a bar for the color sliders, and a drag moves its row without changing a color ([Advanced recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/learn-color-basics/edit-colors-dialog-overview.html)). The dialog opens on the smooth wheel; `display` (`smooth`, `segmented`, `bars`) is the dialog field. `tests_recolor` covers switching, the dialog height, segmented dragging and Brightness, and bar selection, editing, reordering, scrolling, Preview, Cancel and OK. Scores and estimates unchanged. - **Edit Colors parameters:** `edit.colors.saturate` needs a number `intensity`, the value from -100% to 100% that Saturate takes ([Adjusting colors](https://web.archive.org/web/20230128232631/https://helpx.adobe.com/illustrator/using/adjusting-colors.html)), and answers "missing number `intensity`" without one; a number outside the range counts as its nearest end. `edit.colors.adjustBalance` refuses a channel and `color.harmony` refuses `steps` or `amount` that is not a number; both read `null` as a value left out. `tests::editcolors` and `tests::colorguide` cover them. - **Working RGB numeric controls:** the Color panel and Color Picker read RGB, HSB, hex and Web Safe RGB values in the active working RGB space. Editing one RGB channel preserves the other native channels, including colors outside display sRGB. Regressions cover sRGB, Display P3, Wide Gamut RGB and ProPhoto RGB, picker initialization, native numeric round trips, and a slider click with one Undo step. Scores and estimates unchanged. + - **Pasting into hex color fields:** in the hex fields of the Color Picker, Swatch Options, Recolor Artwork, the Color panel, Document Setup, Duotone, Color Themes and Perspective Grid, a click or keyboard focus selects the digits. A pasted code of six digits, with or without `#`, replaces them wherever the caret is. A pasted code of three digits also replaces them, unless the paste holds only those digits and exactly three digits stay; those digits then edit the text in place. A press on another field commits the text as Tab and Enter do. As VectorCraft's own rule, a paste that is not a color leaves the field as it was. Tests: `a_hex_code_pasted_into_the_hex_field_replaces_its_digits` in `crates/ui-egui/src/dialogs/tests.rs`, `define_grid_takes_a_three_digit_color` in `crates/ui-egui/src/dialogs/tests_perspective.rs` and `a_paste_into_a_hex_field_replaces_or_edits_its_digits` in `crates/ui-egui/src/tests/numfields.rs`. - **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates. - **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement. - **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13. @@ -1139,9 +1172,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. 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). - The [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) page describes up to five Pattern brush tiles: sides, inner corner, outer corner, beginning and end. The archived [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page says to add pattern tiles to the Swatches panel and select a swatch from the scroll list for each tile. Pattern Brush Options offers document pattern swatches in all five tile menus, including swatches named like built-in choices. The brush copies each swatch's whole art and tile frame. Side requires art; explicit None on a corner prevents fallback to the other corner's art. After a swatch is deleted, the brush keeps drawing its copied art and shows it as Original. The cited pages do not describe copying the whole art, requiring Side, suppressing corner fallback after explicit None, or showing deleted swatch art as Original. `brush.options` accepts swatch names and an object form for names that match built-in choices, and rejects unknown patterns before editing. Evidence: `tests::brushsym::pattern_tile_choices_copy_whole_swatch_art_and_reject_invalid_side` and `tests::brushsym::pattern_tile_object_selects_reserved_swatch_names` (vectorcraft-engine), `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), and `dialogs::brush_options::tests::pattern_tile_menus_use_document_patterns_and_side_none_keeps_dialog_open`, `dialogs::brush_options::tests::pattern_options_borrowed_swatch_corner_keeps_its_frame`, and `dialogs::brush_options::tests::pattern_options_selects_swatches_with_reserved_names` (vectorcraft-ui-egui). - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing. + The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`). An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame. A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. @@ -1155,6 +1191,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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; six generated style libraries save and load as `.vcstyles`. In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). + - **Load text styles from another document:** the Character Styles and Paragraph Styles panel menus have Load Character Styles… or Load Paragraph Styles… and Load All Styles…, which copy the styles of a document picked in an open dialog, with the swatches and patterns they paint with, in one undo step (`charStyle.load`, `paraStyle.load`, `textStyle.loadAll`); a style the document already has, under its name or a numbered one, is not added again, any other whose name is taken gets a number, and the file's Normal styles are not loaded. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. The Libraries panel holds gradients and patterns: its + button adds the selection's fill or stroke gradient or pattern, a gradient or pattern swatch dragged from the Swatches panel is added, and a click paints the selection through the active Fill/Stroke proxy, a pattern bringing its definition and swatch into the document. A spot colour stays a spot colour: the library keeps its ink name and tint, and using it in any document links the paint to that document's spot swatch of that name, adding it when missing (#5812). @@ -1178,8 +1215,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Transform Each (Reflect X, Reflect Y, Random, Copy, Scale Strokes & Effects, Scale Corners, Transform Objects and Transform Patterns), Create Object Mosaic (gray tiles and deleting the image), Crop Image (its trim), Create Trim Marks (all artboards) and Rearrange All Artboards (by column, Move Artwork with Artboard and Transform Patterns) refuse a value other than true, false or null, such as "false", and change nothing. Create Trim Marks refuses a style that is not text, such as 5. New Artboard refuses a position or size other than a number or null, such as "10", and New Artboard and Artboard Options refuse a name other than text or null, such as 5; each leaves the artboards and the undo history as they were. Artboard Options (Move Artwork, Scale Artwork, Scale Strokes & Effects, Scale Corners, Transform Patterns, locked and hidden art and the lock), Move Artboard (Move Artwork, the copy and Transform Patterns), Duplicate Artboards (Transform Patterns) and Duplicate, Copy and Cut Artboard (the art) refuse a value other than true, false or null, such as "true", and Artboard Options and Move Artboard refuse a position, size or move given as text, such as "10"; each leaves the document, its undo history and the clipboard as they were. + Duplicate, Copy, Cut and Move Artboard and Rearrange All Artboards take `lockedAndHidden` (default: Move Locked and Hidden Artwork with Artboard) and refuse a value other than true, false or null. When art goes along, the journal records the value, so a replay under the other preference takes the same art. The Layers panel's New Layer (on top), Select Art and Target (Shift-click), Move Rows (the copy), Template, Paste Remembers Layers and Release to Layers (Build) refuse a value other than true, false or null, such as "true", and leave the document, the selection, the highlighted rows and the undo history as they were; New Sublayer refuses such a `top` too. Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing. + Find and Replace, Find Next, Change Case, Smart Punctuation, Insert Character and Type on a Path Options refuse a text option other than text or null, such as a replacement of 5, and a switch other than true, false or null, such as "true". Smart Punctuation also refuses a scope other than selection or document, Type on a Path Options a spacing given as text, and Find and Replace `ids` that are not a list of existing object ids. Each leaves the text, the selection and the undo history as they were. The commands that take `keepModel` (fills and strokes, Appearance rows, Live Paint, Shape Builder fills, gradient stops and freeform points, gradient mesh points, type colors, the Symbol Stainer and the swatch commands) refuse a `keepModel` other than true, false or null, such as "true". Toggle Fill/Stroke Focus (`fill`), Invert, Complement, Apply Last Color and Apply Last Gradient (`stroke`), `paint.setFill` and `paint.setStroke` (`focus`, `none`), and `appearance.setItem`, `livePaint.fill`, `livePaint.strokeEdge`, a `shapeBuilder.merge` `fill`, `swatch.new` and `swatch.edit`'s `paint` (`none`) refuse such a value too. Each leaves the document, its undo history and the defaults for new art as they were. The shape tools' commands (Rectangle, Polygon, Star, Flare, Spiral, Arc, Rectangular Grid and Polar Grid), `path.create` (with `anchors` or `d`), the type commands (new type, type in a path, character formats, the byte range a text command takes, Touch Type, font size steps, `text.getRange`, Optical Margin Alignment, System Layout, Fractional Widths, Ruby and Area Type Options), the symbol commands (Place, Delete, the Symbol Sprayer and the symbolism tools), the swatch commands, the Appearance panel's commands, the gradient and freeform gradient commands, Shape Builder, Live Paint and Image Trace refuse an option other than true, false or null and a number given as text, such as "true" or "40", and leave the document and its undo history as they were. - **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font, and Print and Print Preview warn about the type that prints in it; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite and, for those only GDI lists, through GDI (#1258), and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). @@ -1218,7 +1257,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur At a precision of 9 decimals or fewer, SVG export writes each number from an integer when the integer rounds it as the decimal formatter does; the formatter still writes values halfway between two written numbers and values too large for the integer. Exporting one path of 200,000 anchors takes a median of 43.4 ms instead of 167.0 ms on Windows 11, and 20,000 rectangles 81.4 ms instead of 141.4 ms, with the same SVG (`crates/svg/src/lib.rs`). SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`). @@ -1239,8 +1281,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`). + In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). + `document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` that is not a string, such as `"textAs": true`, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number; the error names the param, and nothing opens or is placed. A param left out or given as null keeps its default (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). When the PDF content of a `.ai` has a page for each artboard, the layers are compared with every page on its artboard (until the pages compared take 8,388,608 pixels at the comparison's scale, about 67 letter-size pages, or number 128): art that only the layers or only a later page draws is handled as art that differs on the first page is, and a text object that a later page doesn't draw is left out unless its layer doesn't print (`crates/eps/src/import/tests_native.rs`). When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). @@ -1249,6 +1294,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. + PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). In a `.ai` or EPS opened from its editing data, and in a legacy `.ai`, each anchor is smooth or a corner as the case of the segment operator ending on it says (`l`, `c`, `v`, `y` smooth; `L`, `C`, `V`, `Y` corner), and a closed path's first anchor takes the case of the segment back to it; points and handles stay where they are. Paths, compound paths, clipping paths, guide paths, area type frames and type-on-a-path paths all keep it (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/legacy_ai/tests.rs`). @@ -1303,6 +1349,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. + - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. + - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`. - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. @@ -1341,9 +1389,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When anchors of a path or compound path of 1,024 anchors or more are added or deleted, or a subpath is closed or opened, the next frame writes again only the path elements from the first anchor that changed to the last, and the closing segment when an end anchor changed or the subpath was closed or opened. It measures again only the runs of segments that held them; a run grows or shrinks with the segments added or deleted, and one that grows past 256 segments is split. When a member gains or loses subpaths, the renderer makes the path of that member again. When a compound path gains or loses members, it makes the path of those members and of every member between the first and the last of them again. An edit that adds or deletes anchors in, closes or opens more than 16 subpaths makes the whole path again. The pixels and the cull bounds are the same. The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. - When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 128 ms instead of 405 ms with an open path of 200,000 anchors in view (104 ms with nothing changed), and 14 ms instead of 28 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). + When only anchors of a path or compound path move, the canvas draws again only around the segments that start or end at a moved anchor, grown by the farthest its strokes reach, instead of the whole object before and after. This holds while every fill and stroke is a solid color, and the strokes have no dashes, width profile, arrowheads or brush, and the object has no effects or opacity mask; any other object still counts whole. On a loaded Windows machine at 1600 × 1200, single-threaded in a development build, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors and 65.6 million instead of 180.0 million with 200,000 anchors (medians of 15 edits, the two builds run in turn three times). + Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million. Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460). The canvas keeps the selected objects' outlines, anchors and handles from one frame to the next as one mesh. It builds an object's marks again when the object, its layer color or its selected anchors change. A pan moves the kept outlines and handles and places the anchor squares again. Each subpath and handle gets its triangles the first time it is in view. A frame with a new zoom or rotation draws the marks one shape at a time and keeps nothing, and the next frame at that view builds them. The pixels are the same, apart from two small differences: a kept outline's corner of exactly 90 degrees in a rotated view can keep its miter or cut corner after a pan, and kept vertices can move by rounding errors. On Windows at 1440 × 900, in a release build, with 2,000 selected circles of 50 anchors each in 10 fill colors, an idle frame takes 12.8 ms instead of 59.9 ms with the Selection tool and 29.8 ms instead of 192.1 ms with Direct Selection (medians of five runs). A frame during a zoom takes about what it did: 86.7 ms instead of 80.0 ms with the Selection tool. @@ -1359,6 +1408,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. + Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way. - **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Tool states have cursors of their own as the public manual describes them: Direct Selection shows a hollow or filled square on its white arrow over an anchor (path unselected or selected) and the Reshape Segment cursor over a curved segment, as do the Anchor Point tool and the Pen with Alt (whose caret it lends); Group Selection, and Direct Selection with Alt, show a plus; a drag shows the filled arrow, hollow while it snaps to a point or a guide, and an Alt-drag copy a double arrowhead; Curvature has its own pen and Free Transform's Cmd/Ctrl side-shear two arrows passing along the side. Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors, Mesh and Puppet Warp return cursors of their own, which borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed until they're drawn; there's no busy cursor yet. `crates/tools/src/cursors.toml` lists every cursor, when it shows and its hotspot, for whoever draws them. Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen on Windows, on the web and on Linux under X11 and XWayland, while winit 0.30 reports none on macOS or native Wayland. On Linux, see `docs/development.md` for pens under Wayland. @@ -1389,9 +1439,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live objects in documents with many symbols:** a live object, or an object with effects, that shows symbols or patterns is not walked again in a later frame to find them while the document's symbols and patterns stay the same. One that shows a placed file is walked in every frame. At 1000 × 1000 pixels, a page of 2,000 envelopes around instances of the last 2,000 of 10,000 symbols takes 30 ms a frame, and 83 ms when every frame walks them again, with the same pixels. - **Control bar and task bar on large paths:** the Control bar and the contextual task bar read the first selected object in place each frame instead of copying it; the task bar copies the object only when it is a basic shape, for its Shape Properties. With one selected open path of 200,000 anchors, an idle frame with both bars shown takes 3.8 ms in a release build instead of 8.0 ms (medians of five interleaved runs). Tests: `the_control_bar_label_follows_edits_undo_documents_and_selection`, `the_task_bar_follows_the_active_document`, `the_task_bar_follows_edits_and_undo`. +- **Paste of many objects:** Paste leaves pasted copies as they are when no resource has another name or color in the document and the objects link no graphic style. It looks up the pasted type in one walk of the tree. A paste and undo of 20,000 rectangles in 200 groups uses 49.2 ms of CPU time in a release build instead of 59.4 ms, and of 5,000 point type objects 25.3 ms instead of 29.4 ms (medians of seven interleaved rounds, each round the CPU time of a 61-paste run minus a 1-paste run, divided by 60). Tests: `crates/engine/tests/it/paste_many.rs`. - **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it. +- **Envelopes of type map from the glyphs:** an envelope maps point type, area type, type in groups and layers and type used as a clipping path from the bounds of the glyph outlines, underlines and strikethroughs instead of the type's laid-out box (ascent to descent, or the area's frame), with inline graphics counted by their boxes; rotated type and type in perspective map from their outlines as drawn. Make with Mesh, Make with Top Object and Reset with Mesh without Maintain Shape use those bounds. Type that draws nothing keeps its laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is VectorCraft's own rule; envelopes of type made in VectorCraft and in saved files redraw by it. Tests: `crates/engine/src/tests/envelope.rs`, and `tests/envelope_ink.rs` in `vectorcraft-eps`, `vectorcraft-render` and `vectorcraft-effects`. Scores and hours unchanged. + - **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified. - **Smart Blur modes:** Effect › Blur › Smart Blur… has a Mode menu with Normal, Edge Only and Overlay Edge (`blurMode`: `normal`, `edgeOnly` or `overlayEdge`; an effect saved without it draws as Normal). Edge Only draws the edges in white on black, and Overlay Edge draws them in white over the Normal blur. A pixel is on an edge when one of the pixels the search reads within Radius of it differs from it by more than Threshold in a channel and its channels, alpha included, add up to at least that pixel's. The search reads neighbors as the Normal blur does, kept within Radius, and always the four nearest pixels, so a color step gives a line about as wide as Radius, and at least one pixel wide, on the side with the larger sum (lines on both sides when the sums are equal). The white and the black keep each pixel's coverage. Behavior reference: [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-effects.html). @@ -1402,6 +1455,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Zooming in on large images (#4155):** when an image is drawn from pixels (its mip, or its full pixels) more than 1,024 pixels on a side, and their colours are made again for the frame (separated into a CMYK document's ink planes, colorized, or shown through Separations Preview's plates), only the 1,024-pixel tiles in view are made, each kept with the images under its own key, and the tiles in view are drawn as one pixmap so their edges don't show. The colours are made pixel by pixel, so the result is the same as making them for the whole image (`a_tiled_image_draws_what_the_whole_image_draws`, across a tile edge); `a_zoomed_in_image_is_separated_only_where_it_shows` checks the tiles out of view are not made. A CMYK image painting its own inks is still made whole. On the file of #4155 (an 11,339 × 12,424 photo, M3 Pro, one run each), the sharp frame after zooming in to 50%, 100% and 200% went from 9.0, 8.6 and 8.7 s to 0.78, 0.05 and 0.05 s, and the app's memory after zooming from 4.4 GB to 2.6 GB; the screens at 100% match (p99 difference 0). - **Large images in CMYK documents (#4155):** the canvas draws a reduced image from a mip (halved while still at least as fine as the screen) and, in a CMYK document, separates that mip into its ink planes, colorizes it or shows it through Separations Preview's plates, instead of doing that to the full pixels and shrinking the result. A CMYK image's own inks are halved with its pixels, so it still paints them. Exports draw without mips and are unchanged. On the file of #4155 (one 11,339 × 12,424 image), measured on an M3 Pro under load, one run each: first frame 9.2 s → 1.6 s, opening to the first frame 11.7 s → 3.9 s, the sharp frame after a pan 3.9 s → 8 ms and after a zoom 3.5 s → 0.16 s; the fitted view's pixels differ by at most 5 levels (p99 2). `a_reduced_image_is_separated_from_its_mip_not_its_full_pixels`, `a_reduced_cmyk_image_paints_its_own_inks` and `a_reduced_image_on_the_ink_planes_shows_its_average` (`crates/render/src/tests/memory.rs`) cover the order, the inks and the colour: 1-pixel black and white lines show as the mid grey they average to, where separating first showed them 44 levels darker. - **Panning over a cropped effect raster:** when a pan makes a shadow, glow or filtered content raster cropped to the view again at the same zoom and rotation, the renderer makes it over a margin around the view too (up to half the view's longer side, within 8.4 million pixels), and later pans that keep the view inside that margin draw the raster instead of making it again. Zooming and the first frame at a zoom make the raster without the margin. The canvas pans by whole device pixels: the wheel, a trackpad, a touch screen and the scroll bars add the part of a pixel left over to the next scroll of the same view, and Hand tool and scroll bar thumb drags round the distance dragged. A Drop Shadow or Outer Glow raster holds the shadow or glow of group members outside the view, so a pan that shows a member draws its shadow or glow from the raster. `pans_within_a_cropped_rasters_margin_draw_it_kept` covers Drop Shadow, Gaussian Blur and Feather on a large image at 100%. `a_groups_shadow_raster_holds_the_members_outside_the_view` covers a group's members, `fractional_scrolls_moved_in_whole_pixels_draw_a_cropped_raster_kept` covers scrolls by fractions of a pixel, and `scrolls_and_pans_move_the_view_by_whole_device_pixels` covers the canvas. +- **Panning a heavy document:** when the render worker draws a frame for a view moved by whole device pixels at the same zoom and rotation, it copies the pixels that stay on screen from its last frame and renders only the parts the pan exposes, grown to whole eighths of the frame, as long as those parts are at most half of it. A pan renders whole when the last frame or a part it exposes draws art in a way that depends on the frame's size, such as an effect raster cropped to the view or made at a lower resolution. A copied frame can differ from a whole render of the view (by at most one level in the tests), so the canvas renders the frame whole once it has been shown for 0.2 s with the view unmoved. The worker keeps one extra frame, up to 16 million pixels (64 MiB). A frame after a zoom or an edit holds no pixels, so the first pan step after one renders whole. Scores and estimates unchanged. `a_whole_pixel_pan_copies_the_pixels_that_stay_on_screen`, `a_frame_renders_whole_when_anything_but_a_whole_pixel_pan_changed`, `worker_frames_match_a_fresh_render_through_a_session`, `a_pan_over_art_the_frame_size_decides_renders_whole`, `a_frame_after_a_zoom_or_an_edit_holds_no_pixels` and `a_frame_with_copied_pixels_renders_whole_once_the_view_stops` (`crates/ui-egui/src/canvas/tests.rs`) cover the copy, the frames that render whole, the frames that hold no pixels and the frame after the view stops. - **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it. @@ -1423,13 +1477,19 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. +- **Envelope names given as another type:** `object.envelope.makeWithWarp`, `resetWithWarp` and `options` refuse a `style` or `orientation` other than text or null, such as `5`, and an `orientation` other than `horizontal` or `vertical`; `object.envelope.options` refuses such a `preserveShape`. `object.envelope.options` checks the warp keys with no envelope or only mesh envelopes selected, too. Each leaves the document, the options new envelopes get and the undo history as they were. + - **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.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. - **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged. - **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **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. +- **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. +- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.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`, 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. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. diff --git a/docs/three-d.md b/docs/three-d.md index 7cc339dff..bb0995315 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -14,7 +14,36 @@ Select a path or compound path, then choose **Effect › 3D and Materials › Ex artboard. Preview changes are temporary until OK; Cancel restores the document; OK is one undo step. An object takes one -3D effect: edit its options in the Appearance panel, or remove it before applying another. +3D effect: edit its options in the Appearance panel or the 3D and Materials panel, or remove it before applying +another. + +## 3D and Materials panel + +**Window › 3D and Materials** opens a panel that edits the 3D effect of the selection without a dialog, as the +public [3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html) +and [Create 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-vector-artwork.html) +pages describe it. + +- The object types **Plane**, **Extrude**, **Revolve** and **Inflate** sit at the top. A type applies its effect + to each selected object that has no 3D effect (Plane is Rotate, Extrude is Extrude & Bevel), and changes the + 3D effect of each other selected object in place (`effect.setParams` with `threeDType`): the settings both + effects take keep their values, and the rest start at the new effect's defaults. One click is one undo step, + and an object whose effect already has the type does not change. +- Below them are the Object, Rotation and Lighting tabs of the effect's options dialog, with the same controls + and ranges, showing the 3D effect of the first selected object (its own effects first, then those of its fills + and strokes). A changed setting goes to the 3D effect of each selected object whose effect takes that setting; + other effects and objects without a 3D effect do not change. A setting the selected 3D effects hold with + different values shows its default with its reset dot filled; a checkbox shows a dash instead, and a mixed + Shading leaves the light settings available. Its reset dot, a choice from its menu or a click on its checkbox + (which turns a mixed one on) sets it on all of them; a typed or dragged number sets it when it differs from the + default shown. Other changes leave it as each effect has it. + The list of lights shows the first object's lights, and an edit to it (Add Light, Delete Light, or a setting + of one light in a list) reaches only the objects whose list of lights matches the first object's. Add Light is + unavailable while a setting of a single light differs between the selected effects. +- Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step + when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. +- The Materials tab, shadows and ray-traced rendering are not built (see below). The rotation and light rings on + the artboard are part of the options dialog only. ## Extrude & Bevel diff --git a/docs/type-parity.md b/docs/type-parity.md index b36cb4892..010c74aa5 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -21,7 +21,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | +| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | | Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3117a7af1..6b8bda8d1 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -63,6 +63,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("plugins", Class::Layer(2)), ("affinity", Class::Standalone), ("scriptcheck", Class::Standalone), + ("js", Class::Standalone), ]; /// Explicit orderings *within* a layer (earlier may be used by later). diff --git a/xtask/src/main.rs b/xtask/src/main.rs index de6ff5815..334910732 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -14,6 +14,7 @@ mod pdf_deps; mod pngsuite; mod sha256; mod stats; +mod test262; mod version; mod web_fonts; @@ -41,6 +42,8 @@ commands: ico ... pack PNGs into a Windows .ico (used by packaging/icons.sh) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 + verified) and parse its ES5 subset with vectorcraft-js, printing the counts web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; @@ -64,6 +67,7 @@ fn main() -> ExitCode { Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("test262") => test262::run_suite(), Some("web-fonts") => web_fonts::run(&root()), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); @@ -333,6 +337,8 @@ Tests that use a corpus skip cleanly when it is absent. corpus/raw/ raw.pixls.us samples, CC0 (optional) corpus/affinity/ public Affinity documents (CC0/MIT/Apache-2.0) at pinned commits, sha256 verified: oracles for the Affinity reader. Fetch: cargo xtask corpus --affinity + corpus/test262/ the ECMAScript conformance suite (BSD-3-Clause) at a pinned commit, sha256 + verified: the JavaScript parser's ES5 subset. Fetch and run: cargo xtask test262 ", corpus.display() ); diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs new file mode 100644 index 000000000..ab390538b --- /dev/null +++ b/xtask/src/test262.rs @@ -0,0 +1,73 @@ +//! `cargo xtask test262`: the official ECMAScript conformance suite (https://github.com/tc39/test262, +//! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against +//! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then +//! runs `vectorcraft-js`'s `test262` test, which parses the ES5 parse subset and prints its counts. +//! +//! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the +//! archive's SHA-256 in the error, check the archive, and update [`SHA256`]. + +use std::path::Path; +use std::process::Command; + +use crate::{cargo, root, run, sha256}; + +/// The tc39/test262 commit the suite is read at. +const COMMIT: &str = "d53ebc8b1931d9f1541e43d59650776e4b766248"; + +/// The SHA-256 of GitHub's `tar.gz` archive of [`COMMIT`]. +const SHA256: &str = "6db8566044d1a3f5a68aac87806e09b2fe5d21f5869f3a74cfd8b7236ebaf5c8"; + +/// The largest archive accepted (the pinned one is 9.6 MB). +const MAX_BYTES: u64 = 64 << 20; + +pub fn run_suite() -> Result<(), String> { + let dir = fetch()?; + let mut c = cargo(); + c.env("VECTORCRAFT_TEST262_DIR", &dir).args(["test", "-p", "vectorcraft-js", "--release", "--test", "test262", "--", "--nocapture"]); + run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display())) +} + +/// Fetches and unpacks the suite unless it is there already, and returns its folder. +fn fetch() -> Result { + let base = root().join("corpus").join("test262"); + let dir = base.join(format!("test262-{COMMIT}")); + let marker = dir.join(".vectorcraft-sha256"); + if std::fs::read_to_string(&marker).is_ok_and(|s| s.trim() == SHA256) { + println!("test262 {COMMIT} is in {}", dir.display()); + return Ok(dir); + } + std::fs::create_dir_all(&base).map_err(|e| format!("create {}: {e}", base.display()))?; + let archive = base.join(format!("test262-{COMMIT}.tar.gz")); + if !verified(&archive)? { + let url = format!("https://codeload.github.com/tc39/test262/tar.gz/{COMMIT}"); + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "--retry", "3", "-A", "VectorCraft-dev", "--max-filesize", &MAX_BYTES.to_string(), "-o"]).arg(&archive).arg(&url); + run(curl, &format!("curl {url}"))?; + if !verified(&archive)? { + let got = std::fs::read(&archive).map(|b| sha256::hex(&b)).unwrap_or_default(); + let _ = std::fs::remove_file(&archive); + return Err(format!("test262 archive: sha256 {got} does not match the pinned {SHA256}")); + } + } + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|e| format!("remove {}: {e}", dir.display()))?; + } + let mut tar = Command::new("tar"); + tar.arg("-xzf").arg(&archive).arg("-C").arg(&base); + run(tar, &format!("tar -xzf {}", archive.display()))?; + if !dir.join("test").join("language").is_dir() { + return Err(format!("{} has no test/language folder after unpacking", dir.display())); + } + std::fs::write(&marker, SHA256).map_err(|e| format!("{}: {e}", marker.display()))?; + println!("test262 {COMMIT} unpacked into {}", dir.display()); + Ok(dir) +} + +/// The archive is there, within the size limit, with the pinned SHA-256. +fn verified(path: &Path) -> Result { + let Ok(meta) = std::fs::metadata(path) else { return Ok(false) }; + if meta.len() > MAX_BYTES { + return Err(format!("{} is {} bytes, over the {MAX_BYTES}-byte limit", path.display(), meta.len())); + } + Ok(std::fs::read(path).is_ok_and(|b| sha256::hex(&b) == SHA256)) +}