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/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/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/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 22159ca2a..98824d142 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -669,6 +669,9 @@ impl SymbolOptions { #[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 +693,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); + } } } @@ -822,6 +827,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 } 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/edit.rs b/crates/engine/src/cmd/edit.rs index cf5a31adf..1799a4da3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -139,7 +139,7 @@ pub fn specs() -> Vec { "Cut", ["Edit"], Some("Cmd+X"), - "{selectedArtboards?: [n…], art?: bool} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut)", + "{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut)", has_selection_guides_or_artboard_tool, cut ), @@ -148,7 +148,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards)} 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}", + "{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), 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 ), @@ -707,11 +707,11 @@ fn takes_artboards(s: &Session, p: &Value) -> bool { s.tool_id() == "artboard" || p.get("selectedArtboards").is_some() } -/// Copy or Cut of artboards: `selectedArtboards` and `art` of `p`, or their defaults from the -/// Artboard tool, which the journal entry records. +/// Copy or Cut of artboards: `selectedArtboards`, `art` and `lockedAndHidden` of `p`, or their +/// defaults from the Artboard tool, which the journal entry records. fn copy_artboards(s: &mut Session, p: &Value, cut: bool) -> Result { let given: serde_json::Map = - ["selectedArtboards", "art"].into_iter().filter_map(|key| p.get(key).map(|v| (key.to_string(), v.clone()))).collect(); + ["selectedArtboards", "art", "lockedAndHidden"].into_iter().filter_map(|key| p.get(key).map(|v| (key.to_string(), v.clone()))).collect(); let given = Value::Object(given); super::panelcmds::boards_and_art_noted(s, &given, if cut { "edit.cut" } else { "edit.copy" })?; super::panelcmds::artboard_copy(s, &given, cut) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index a823ee161..b67d5bb97 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -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 ), @@ -828,12 +828,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); 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/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/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/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/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 95f8c3e8f..3c16fba87 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -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 ), @@ -1274,7 +1274,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 +1291,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/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f0f978931..3e5e11593 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")?; @@ -252,14 +252,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), @@ -269,11 +279,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) @@ -290,7 +300,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, _| { @@ -315,8 +326,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/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 519d282e8..26db35c3d 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 ), @@ -432,7 +432,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 +498,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 +565,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 +660,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..6d18bb5eb 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -108,11 +108,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); } }); 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/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/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/session/documents.rs b/crates/engine/src/session/documents.rs index 41946c0eb..52dcba146 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -44,6 +44,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, diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 07959cf0e..c5829b31e 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"}` @@ -162,8 +166,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); @@ -190,6 +194,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)> { @@ -208,9 +235,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/tests/journal.rs b/crates/engine/src/tests/journal.rs index 016270c88..29eae431d 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})); } @@ -348,7 +363,7 @@ fn edit_copy_and_cut_with_the_artboard_tool_replay_on_its_artboards() { let r = replayed(&s); assert_ne!(r.tool_id(), "artboard"); assert_eq!(documents(&r), documents(&s), "{id} after {select}"); - assert_eq!(entry, (id.to_string(), json!({"selectedArtboards": [1], "art": true})), "{id} after {select}"); + assert_eq!(entry, (id.to_string(), json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false})), "{id} after {select}"); } } } @@ -362,7 +377,7 @@ fn edit_copy_after_a_paste_with_the_artboard_tool_replays_on_the_pasted_artboard s.execute("edit.copy", &json!({})).unwrap(); s.execute("edit.paste", &json!({})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); - assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [3], "art": true})); + assert_eq!(s.journal.last().unwrap().1, json!({"selectedArtboards": [3], "art": true, "lockedAndHidden": false})); s.execute("edit.paste", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.artboards.len(), 5); assert_eq!(documents(&replayed(&s)), documents(&s)); @@ -390,7 +405,7 @@ fn edit_copy_and_cut_with_the_artboard_tool_record_nothing_for_an_action() { let start = second_artboard_selected(&mut s); let steps = json!([{"command": id, "params": {}}, {"command": "edit.paste", "params": {}}]); s.execute("command.batch", &json!({ "commands": steps })).unwrap(); - assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "art": true}), "{id}"); + assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), "{id}"); assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); assert_eq!(s.journal_for_action(start)[0].1["commands"][0]["params"], json!({}), "{id}"); } 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/xform.rs b/crates/engine/src/tests/xform.rs index 5dfd7ca09..ce2d3a3e7 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -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/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f1a1f62c0..6c0f3167c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3043,6 +3043,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 +3222,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] @@ -3260,6 +3302,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] 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/main.rs b/crates/engine/tests/it/main.rs index f17d27e4d..a6f0b02c7 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -22,6 +22,7 @@ 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/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 2ad1c719c..17f717a11 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 @@ -220,6 +241,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> 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 +493,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..e4a1edff5 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1015,6 +1015,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] @@ -1376,8 +1396,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 +1421,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 +2089,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 +2105,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); @@ -4132,3 +4152,279 @@ fn type_with_an_opacity_mask_is_written_as_its_outlines() { assert!(!matches!(top.kind, NodeKind::Text(_))); assert!(top.extras().mask.is_some()); } + +// ---------- 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..2ac1edd63 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -18,6 +18,7 @@ use crate::ps; mod images; mod paints; mod texts; +mod variables; pub(super) use images::PAYLOAD; #[cfg(test)] @@ -26,6 +27,8 @@ pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; #[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 @@ -160,6 +163,13 @@ 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, } @@ -181,6 +191,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(), @@ -491,23 +504,34 @@ 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`) or has an opacity mask (`AI9_Mask`, see [`Self::mask`]). 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() { + if name.is_none() && mask.is_none() && bound.is_empty() { return; } self.body.push_str("%_/ArtDictionary :\n"); - if let Some(name) = name { - let id = self.take_id(name); + 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); @@ -931,6 +955,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..0248e8b9a 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -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 => { + // 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); 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..51c604228 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -72,7 +72,7 @@ use obj::PsError; pub(crate) use ai::MAX_GRADIENT_STOPS; 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..57d240f9f 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -38,8 +38,8 @@ pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; 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}; diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 93d23f542..0bb41977f 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -70,6 +70,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; diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 4e6164e4b..1fb369053 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -78,6 +78,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 +112,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, @@ -632,7 +643,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 +656,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 +681,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..4dbf31a24 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -134,10 +134,12 @@ 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 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}; @@ -513,7 +515,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 +588,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. @@ -735,6 +740,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..307105f95 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; @@ -760,7 +763,10 @@ impl<'a> Reader<'a> { _ => Some(Node::group(self.doc.alloc_id(), art)), }; 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..084f4d4b6 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. @@ -1041,6 +1091,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 +1115,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(); 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..b8c6daf6c 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); diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 17f843d9a..d47805e97 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; @@ -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/tests.rs b/crates/eps/src/import/ai/tests.rs index 36a93a690..e736fe6da 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 @@ -3426,6 +3554,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. @@ -3700,7 +3853,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 +3862,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:?}"); } @@ -5206,8 +5357,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 +5368,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] @@ -7797,6 +7948,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. @@ -8552,13 +8815,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 +8849,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 +8953,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/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/tests_native.rs b/crates/eps/src/import/tests_native.rs index dde26bc8d..6f21d060b 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")); @@ -3857,6 +3858,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 +4171,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 +4191,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 +4251,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 +4518,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] 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/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/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0b508abfa..2a4d6945c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -255,6 +255,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 +495,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); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index bcd7c578f..982972770 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 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/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 43e274505..ed9f909b3 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; @@ -372,6 +373,8 @@ registry! { 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/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/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8ab9cb9b8..555b21d1f 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -952,6 +952,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] 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/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f6a8debdc..0da4c8b03 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5680,6 +5680,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 Стъпка по стъпка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a8db1ebea..a90a73f64 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5703,6 +5703,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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3898a677f..42befdbc1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3803,6 +3803,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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eff1d2d34..4fcc1bcf4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5796,6 +5796,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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index abfe581e7..43da801a7 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5695,6 +5695,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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4a4142d88..d0d3fcc9e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5378,6 +5378,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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6b2a022d5..7c290163b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5747,6 +5747,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 Βήμα προς βήμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6ccd50b46..aff3c9b87 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5737,6 +5737,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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3cf8607c0..58da7396e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5826,6 +5826,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 67a7cb29c..fe935bf47 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5791,6 +5791,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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e1af82a3a..ea6911fd2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3290,6 +3290,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 प्लगइन जानकारी diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 82f75da72..c97563bee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5779,6 +5779,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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7230b6bfa..9bd658195 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5695,6 +5695,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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 723fa4ab0..38b72c8c5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5412,6 +5412,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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8bb101269..4c25ed744 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5477,6 +5477,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 ステップ実行 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 343b415a4..1bada5e40 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5719,6 +5719,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 ნაბიჯ-ნაბიჯ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f4cc45b82..1039fba60 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5373,6 +5373,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 단계별 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2e8d6f34c..00431e1a9 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5741,6 +5741,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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c9e1256c3..3b52097d4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5649,6 +5649,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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 062b7d88b..219c6e8e7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5711,6 +5711,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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a83d9edf3..1186b6df8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5407,6 +5407,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 Пошагово diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 780d23bea..c491e1abf 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5819,6 +5819,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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 55a9cdc73..7d6676c87 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5753,6 +5753,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 +5944,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ı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bbfc332f9..11f6bed60 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5392,6 +5392,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 Покроково diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 47915fb17..f470e83b8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5393,6 +5393,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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e646c956b..0f0f90caa 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5634,6 +5634,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 逐步 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 468ad851a..ccb88d64c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5430,6 +5430,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 逐步 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index afb8cbbc4..21c000c12 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::{ diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index dbee7082f..8ccb80c04 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -848,6 +848,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/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 988abad5b..41504f5e2 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1269,7 +1269,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 +1287,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", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index ff39d90cf..e2cb86d34 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -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..5b538b765 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(); 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/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_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/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/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/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/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..d4dc7775e 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 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/widgets.rs b/crates/ui-egui/src/widgets.rs index 5a6daf1fa..bb5bf4a72 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -32,7 +32,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, @@ -520,12 +520,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/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..d7ca5ab06 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -252,17 +252,31 @@ 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 (status and hint bars) 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(super) 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 +285,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/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/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/file-format-parity.md b/docs/file-format-parity.md index f60d46f62..14a233f4b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 @@ -90,6 +91,17 @@ private editing data. VectorCraft: 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. + 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 @@ -163,6 +175,19 @@ 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; 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. diff --git a/docs/gaps.md b/docs/gaps.md index 4dac9431e..6780782ab 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. diff --git a/docs/mcp.md b/docs/mcp.md index e948a3963..da5f86a60 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -875,6 +875,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 +1082,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 +1130,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 +1168,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. @@ -1844,11 +1852,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 +1901,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 +1933,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 @@ -2938,6 +2972,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 +3124,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}}} @@ -3481,7 +3522,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) @@ -3935,8 +3977,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 +3991,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 +4001,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 @@ -4072,7 +4117,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 @@ -4750,8 +4795,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). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 04d7b6726..db55f80b7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,6 +201,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 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 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. | | 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) | @@ -558,6 +559,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. @@ -631,6 +633,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,6 +652,7 @@ 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`). @@ -674,6 +678,8 @@ 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. + - 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`). @@ -705,6 +711,7 @@ 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). + - **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. @@ -965,6 +972,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`. @@ -1109,6 +1117,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. @@ -1179,6 +1188,7 @@ 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. 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. @@ -1220,6 +1230,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 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`). @@ -1388,6 +1399,7 @@ 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.