From 3617987e353aa67ced3ac1317bbae731c3f548cf Mon Sep 17 00:00:00 2001 From: Jared Smith Date: Fri, 6 Nov 2026 15:18:21 -0700 Subject: [PATCH 01/52] #5980: macOS save sheet uses alloc/init instead of +savePanel +[NSSavePanel savePanel] returned nil inside VectorCraft on macOS 15.7 and objc2 panicked; build the panel with init (and Option fallbacks) so Save As shows again, and do not hand a refused panel to rfd. Co-authored-by: Cursor --- apps/vectorcraft/src/host_services.rs | 7 ++- apps/vectorcraft/src/mac_save_format.rs | 69 +++++++++++++++++++++---- 2 files changed, 63 insertions(+), 13 deletions(-) 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() { From 21249399c650661f0946cbbf1f46c61ab40798fa Mon Sep 17 00:00:00 2001 From: Luis <43929895+Luis-0508@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:07:42 +0100 Subject: [PATCH 02/52] Release: refresh contributor credits for each release (#5997) (#5998) About > Contributors still showed an old snapshot, because nothing regenerated contributors/contributors.json before a release. Opt in to the shared refresh from craft-common-actions: a read-only credits job regenerates the snapshot for the release commit, and the shared release workflow installs it before every source build. The snapshot is not committed back. --- .github/workflows/release.yml | 9 ++++++++- docs/contributors.md | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) 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/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 From 74863fa7789805c33ce1747ff3a13b853766ffdf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 22:07:59 -0800 Subject: [PATCH 03/52] Engine: execute refuses keys a command doesn't name, in every build Session::execute ran check_params only in the engine's own tests, so a direct execute in a release build ran a misspelled key with the command's default. It now checks every call, the commands a command runs included, as MCP and the control port already did. A command.batch or an action.batch is checked before its first step runs, and each step is checked again as it runs, so an effect.setParams step is checked against the effects the steps before it added. Saved steps play through Session::replay, which takes keys a command no longer reads, so an action recorded by an older version still plays and Session::replay still plays an older journal. The Actions panel's Play (Accelerated, Step by Step and Pause For, its function keys and ui.action.play) and the Batch dialog use it. Docs: the cmd/params.rs module doc, the Parameter keys section of docs/mcp.md and a line in docs/target-app-parity.md describe both paths. --- crates/engine/src/cmd/params.rs | 32 ++++--- crates/engine/src/lib.rs | 3 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/session/execute.rs | 35 ++++++- crates/engine/tests/it/param_keys.rs | 101 +++++++++++++++++++-- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/app/commands.rs | 15 ++- crates/ui-egui/src/app/control.rs | 1 + crates/ui-egui/src/dialogs/action_batch.rs | 21 ++++- crates/ui-egui/src/panels/actions.rs | 28 +++++- docs/mcp.md | 24 +++-- docs/target-app-parity.md | 1 + 12 files changed, 229 insertions(+), 35 deletions(-) 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/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 f993ac585..8d8568a34 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"}` @@ -186,6 +190,29 @@ impl Session { Ok((r?, noted)) } + /// [`Session::execute`] for saved steps: a journal entry or an action's steps, recorded by this + /// version or an older one. Keys a command's params description doesn't name (a key it no longer + /// reads, an effect parameter it no longer takes) are taken, by the commands it runs too, so what + /// was recorded still plays (#1442). Everything else is checked as `execute` checks it. + pub fn replay(&mut self, id: &str, params: &Value) -> Result { + let outer = self.set_replaying(true); + let r = self.execute(id, params); + self.set_replaying(outer); + r + } + + /// Whether [`Session::execute`] plays saved steps as [`Session::replay`] does. A frontend that + /// runs saved steps through its own path to `execute` sets it around that call, and puts back the + /// value this returns, which it had before. + pub fn set_replaying(&mut self, on: bool) -> bool { + std::mem::replace(&mut self.replaying, on) + } + + /// Whether saved steps are playing ([`Session::set_replaying`]). + pub fn replaying(&self) -> bool { + self.replaying + } + /// The journal from entry `start` on, as an action records it: without the params that only /// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now. pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> { 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/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/action_batch.rs b/crates/ui-egui/src/dialogs/action_batch.rs index 3d8bfd4ac..fc7c7ceb3 100644 --- a/crates/ui-egui/src/dialogs/action_batch.rs +++ b/crates/ui-egui/src/dialogs/action_batch.rs @@ -112,7 +112,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 +183,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/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index afddf276f..58ed08493 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -225,7 +225,7 @@ 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()) }; @@ -245,7 +245,7 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> 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)) } @@ -1277,6 +1277,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()); diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..5e8a10463 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1080,10 +1080,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 +1128,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: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..184126541 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -649,6 +649,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`). From b8233f7c19278588dd24b6d9f265dfef9de347e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 11:04:47 +0700 Subject: [PATCH 04/52] .ai save: a reopened file opens on the artboard active when it was saved Squashed from #5975 onto main. --- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 9 +++- crates/engine/src/cmd/fileio/save.rs | 2 +- .../engine/src/cmd/fileio/tests_aiimport.rs | 47 +++++++++++++++++++ crates/engine/tests/import_fuzz.rs | 17 +++++++ crates/eps/src/ai_editing/tests.rs | 20 ++++++++ crates/eps/src/import.rs | 2 +- crates/eps/src/import/ai.rs | 4 +- crates/eps/src/import/ai/read.rs | 4 +- crates/eps/src/import/ai/read/document.rs | 33 +++++++++---- crates/eps/src/import/native.rs | 2 +- crates/eps/src/import/native/layers.rs | 9 ++++ crates/eps/src/lib.rs | 4 +- crates/ui-egui/src/tests/save.rs | 16 +++++++ docs/file-format-parity.md | 3 +- docs/mcp.md | 5 +- 16 files changed, 157 insertions(+), 22 deletions(-) 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/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f1a1f62c0..4eae16b21 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3260,6 +3260,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/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 7ebece9d3..06fa1dbd2 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] 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/read.rs b/crates/eps/src/import/ai/read.rs index 025ccc480..f96784543 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -134,7 +134,9 @@ 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}; diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 65d949f0f..106c07dd0 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) ,"; 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/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/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/docs/file-format-parity.md b/docs/file-format-parity.md index f60d46f62..21c35d24f 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 diff --git a/docs/mcp.md b/docs/mcp.md index 5e8a10463..fe2d705ca 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 @@ -4749,8 +4751,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). From 199c4c3467009753c5d29a79617b7a45c5c4f79f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 12:22:22 -0800 Subject: [PATCH 05/52] Simplify: fitted curves stay within the tolerance of the path, and serpentines simplify faster The curve fitter split a piece over the tolerance at its worst point and accepted any piece at depth 41 whatever its error. A worst point near the end of a piece peels off a few points, so a long path refitted nearly all of its points at every level. A serpentine of 200,000 anchors kept pieces with a fitted error of up to about 1,598 pt at a tolerance of 1 pt. Simplify also took at most 256 samples per segment, so the lines between the samples on a long arc could be farther from it than the tolerance. - `fit_rec` moves a split that is near an end of its piece to two fifths of the piece from that end, so the depth is logarithmic, and it keeps no piece over the tolerance. A piece of 32 points or more is first fitted to about 16 of them, and it is split when that fit is over the tolerance. - A curve is also rejected when it is over the tolerance from the lines between the points it fits, with samples close enough that it stays within 1% of the tolerance of the chord between them. A pair of points that would need more than 1,024 samples counts as too far. - `join_pieces` joins neighboring pieces when one curve fits them, up to 33 pieces per curve. - `sample_steps` takes enough samples of a curved segment to keep it within 0.5% of the tolerance of the lines between them. A segment that would need more than 65,536 samples 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 Convert to Straight Lines such a segment becomes evenly spaced lines, up to 65,536. A fit holds at most 1,048,576 samples, so a longer run is fitted in parts. - Simplify fits at 98% of the tolerance, since turning a nearly straight curve into a line moves it by up to 1.5%. - Above slider level 0, Object > Path > Smooth fits a whole closed path with no corner along one direction across its start, when no segment stays as it is and the path is fitted as one part. A direct-selected section does not get this. The start can still have a kink: a first or last fitted curve that is nearly straight becomes a line, which leaves along its chord, and a refit with more anchors than the path is discarded, so the path stays as it is. A closed subpath that refits to one curve is still dropped, as on main, so it can end up farther than the tolerance from the result. docs/mcp.md states this. The anchors of every simplified path can differ from before, and so can the results of the Pencil, Blob Brush and Smooth, which use the same fit. --- crates/pathops/src/edit.rs | 135 ++++++-- crates/pathops/src/fit.rs | 668 +++++++++++++++++++++++++++++++++++-- docs/mcp.md | 9 +- docs/target-app-parity.md | 1 + 4 files changed, 767 insertions(+), 46 deletions(-) diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 7d289e135..d86f957aa 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,34 +163,67 @@ 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 diff --git a/crates/pathops/src/fit.rs b/crates/pathops/src/fit.rs index 091164d20..4443e5b74 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,513 @@ 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 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/docs/mcp.md b/docs/mcp.md index 9d57428b3..aeb3c12e6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3096,8 +3096,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}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 906ea0be5..649781f13 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -965,6 +965,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`. From 4a7d732197bf7a5748d4320b7cde4003fc25d076 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 16:21:46 -0800 Subject: [PATCH 06/52] Simplify: smoothing a densely sampled path takes time in proportion to its samples Object > Path > Smooth weighed every sample within three spreads of each point, so the work grew with the square of the anchor count on traced paths: 360 steps per sample for 1,000 anchors and 2,949 for 16,000. Consecutive samples within 1/16 of a spread of each other are now weighed together as their count at their mean place, found through a tree of sums over the run's samples. A traced outline takes 203 to 286 steps per sample from 1,000 to 64,000 anchors, and every smoothed point stays within a thousandth of a spread of weighing the samples one by one (300 random runs). --- crates/pathops/src/edit.rs | 227 +++++++++++++++++++++++++++++++++++-- 1 file changed, 218 insertions(+), 9 deletions(-) diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index d86f957aa..338900041 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -226,9 +226,22 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, ends: Op /// `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(); } @@ -240,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) @@ -255,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 { @@ -893,3 +989,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}"); + } + } +} From a0bd9bbbadd5e3b0823308efbf666f9c5d1e9736 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 11:04:54 +0700 Subject: [PATCH 07/52] Artboards: duplicate, copy, cut, move and rearrange journal whether locked and hidden art goes Squashed from #5994 onto main. --- crates/engine/src/cmd/edit.rs | 15 +++-- crates/engine/src/cmd/menucmds.rs | 6 +- crates/engine/src/cmd/panelcmds.rs | 41 +++++++++----- crates/engine/src/cmd/xform.rs | 5 +- crates/engine/src/session/execute.rs | 13 +++-- crates/engine/src/tests/journal.rs | 29 +++++++--- crates/engine/src/tests/panelcmds.rs | 85 ++++++++++++++++++++++++++++ crates/engine/src/tests/xform.rs | 25 ++++++++ docs/mcp.md | 18 +++--- docs/target-app-parity.md | 1 + 10 files changed, 196 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f4b2d21d2..529d9513c 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"), - "{} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)", has_selection_guides_or_artboard_tool, cut ), @@ -148,7 +148,7 @@ pub fn specs() -> Vec { "Copy", ["Edit"], Some("Cmd+C"), - "{} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", + "{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}", has_selection_guides_or_artboard_tool, copy ), @@ -701,10 +701,15 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { Ok((direct, roots)) } -fn copy(s: &mut Session, _: &Value) -> Result { +/// The params edit.copy and edit.cut pass on to `artboard.copy` and `artboard.cut`. +fn artboard_clipboard_params(p: &Value) -> Value { + p.get("lockedAndHidden").map_or_else(|| json!({}), |v| json!({ "lockedAndHidden": v })) +} + +fn copy(s: &mut Session, p: &Value) -> Result { // The Artboard tool copies its artboard (and the art on it). if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &json!({}), false); + return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false); } let (direct, targets) = copy_cut_targets(s)?; let st = s.doc()?; @@ -744,7 +749,7 @@ fn copy(s: &mut Session, _: &Value) -> Result { fn cut(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { - return super::panelcmds::artboard_copy(s, &json!({}), true); + return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true); } // Ruler guides selected on their own: Copy takes them, Clear removes them. if s.doc()?.selection.is_empty() { diff --git a/crates/engine/src/cmd/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 2e3ae4301..6eeafd7c3 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun}; use vectorcraft_geom::{Rect, Vec2}; -use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices}; +use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or}; use crate::{Clipboard, EngineError}; pub fn specs() -> Vec { @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Duplicate Artboard", ["Window", "Artboards"], None, - "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", + "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), lockedAndHidden?: bool (locked and hidden art goes too; default: prefs moveLockedWithArtboard)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art`, in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", has_doc, artboard_duplicate ), @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Copy Artboard", [], None, - "{index? | indices?, selectedArtboards?, art?} (as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}", has_doc, |s, p| { boards_and_art_noted(s, p, "artboard.copy")?; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Cut Artboard", [], None, - "{index? | indices?, selectedArtboards?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, selectedArtboards?, art?, lockedAndHidden?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless lockedAndHidden); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", has_doc, |s, p| { boards_and_art_noted(s, p, "artboard.cut")?; @@ -253,14 +253,24 @@ pub(crate) fn refuse_locked(d: &vectorcraft_doc::Document, indices: &[usize]) -> } } +/// Does locked and hidden art go with an artboard (`lockedAndHidden` param, else prefs +/// moveLockedWithArtboard)? Noted in the journal when `art` goes along, so a replay takes the same +/// art whatever the preference is then. +pub(crate) fn locked_and_hidden(s: &mut Session, p: &Value, art: bool, cmd: &str) -> Result { + let all = switch_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard, cmd)?; + if art { + s.note_journal("lockedAndHidden", json!(all)); + } + Ok(all) +} + /// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden -/// ones only with prefs moveLockedWithArtboard), in paint order. With `moving` (a move or a cut), -/// the art also on another locked artboard stays unless prefs moveLockedWithArtboard -/// ([`vectorcraft_doc::Document::art_moving_with`]); a copy takes it. -pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Result> { +/// ones only when `all`), in paint order. With `moving` (a move or a cut), the art also on another +/// locked artboard stays unless `all` ([`vectorcraft_doc::Document::art_moving_with`]); a copy +/// takes it. +pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, all: bool, moving: bool) -> Result> { let d = &s.doc()?.doc; let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; - let all = s.prefs.move_locked_with_artboard; Ok(match (art, moving) { (false, _) => vec![], (true, false) => d.art_on_artboard(rect, all), @@ -270,11 +280,11 @@ pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Re /// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object /// counted once, with the first of them it is on (artboards can overlap). -pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, moving: bool) -> Result)>> { +pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, all: bool, moving: bool) -> Result)>> { let mut seen = std::collections::HashSet::new(); let mut arts = vec![]; for &i in indices { - let art = artboard_art(s, i, art, moving)?.into_iter().filter(|id| seen.insert(*id)).collect(); + let art = artboard_art(s, i, art, all, moving)?.into_iter().filter(|id| seen.insert(*id)).collect(); arts.push((i, art)); } Ok(arts) @@ -291,7 +301,8 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { check_switches(p, &["patterns"], "artboard.duplicate")?; let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?; - let arts = artboards_art(s, &indices, art, false)?; + let locked_art = locked_and_hidden(s, p, art, "artboard.duplicate")?; + let arts = artboards_art(s, &indices, art, locked_art, false)?; let all: Vec = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect(); let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() }; let (index, ids) = s.edit("Duplicate Artboard", |d, _| { @@ -316,8 +327,10 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { /// Artboard Copy / Cut: the artboards and their art onto the clipboard (pasting brings them all); /// a cut then deletes them. pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result { - let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?; - let arts = artboards_art(s, &indices, art, cut)?; + let cmd = if cut { "artboard.cut" } else { "artboard.copy" }; + let (indices, art) = boards_and_art(s, p, cmd)?; + let locked_art = locked_and_hidden(s, p, art, cmd)?; + let arts = artboards_art(s, &indices, art, locked_art, cut)?; let st = s.doc()?; if cut && st.doc.artboards.len() <= indices.len() { return Err(EngineError::Other("a document needs at least one artboard".into())); diff --git a/crates/engine/src/cmd/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/session/execute.rs b/crates/engine/src/session/execute.rs index 8d8568a34..21822583d 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -162,8 +162,8 @@ impl Session { } /// Record `key: value` in the running top-level command's journal entry (or its interaction's - /// preview, or its step in a batch) unless its params give `key`: a value it resolved from the - /// preferences or the clock. + /// preview, or its step in a batch) unless its params give `key` (a null gives nothing): a value + /// it resolved from the preferences or the clock. pub fn note_journal(&mut self, key: &str, value: Value) { if self.depth == self.note_depth { self.journal_note.insert(key.to_string(), value); @@ -231,9 +231,14 @@ impl Session { pub(super) fn noted(&mut self, params: &Value) -> Value { let mut note = std::mem::take(&mut self.journal_note); match params { - // The params' own values win. + // The params' own values win, except a null: it reads as a key left out, so the + // noted value is the one the command resolved. Value::Object(m) if !note.is_empty() => { - note.extend(m.clone()); + for (k, v) in m { + if !v.is_null() || !note.contains_key(k) { + note.insert(k.clone(), v.clone()); + } + } Value::Object(note) } Value::Null if !note.is_empty() => Value::Object(note), diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 26fcc0835..6d4e1a084 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -268,9 +268,24 @@ fn artboard_commands_record_the_selected_artboards_for_a_replay_and_not_for_an_a for (id, params, recorded, action) in [ ("artboard.switchOrientation", json!({}), json!({"selectedArtboards": [1], "scaleArt": false}), json!({"scaleArt": false})), ("artboard.rename", json!({"name": "Back"}), json!({"name": "Back", "selectedArtboards": [1]}), json!({"name": "Back"})), - ("artboard.duplicate", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), - ("artboard.cut", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), - ("artboard.copy", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), + ( + "artboard.duplicate", + json!({}), + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + json!({"art": true, "lockedAndHidden": false}), + ), + ( + "artboard.cut", + json!({}), + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + json!({"art": true, "lockedAndHidden": false}), + ), + ( + "artboard.copy", + json!({}), + json!({"selectedArtboards": [1], "art": true, "lockedAndHidden": false}), + json!({"art": true, "lockedAndHidden": false}), + ), ] { let mut s = Session::new(); let start = second_artboard_selected(&mut s); @@ -303,14 +318,14 @@ fn artboard_commands_keep_a_named_artboard_and_note_a_batch_steps_artboards() { s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap(); assert_eq!(s.journal.last().unwrap().1, json!({"index": 0, "scaleArt": false})); s.execute("artboard.duplicate", &json!({"indices": [0]})).unwrap(); - assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true})); + assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true, "lockedAndHidden": false})); let steps = json!([{"command": "artboard.switchOrientation", "params": {}}]); s.execute("command.batch", &json!({ "commands": steps })).unwrap(); assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "scaleArt": false})); assert_eq!(documents(&replayed(&s)), documents(&s)); let action = s.journal_for_action(start); assert_eq!(action[0].1, json!({"index": 0, "scaleArt": false})); - assert_eq!(action[1].1, json!({"indices": [0], "art": true})); + assert_eq!(action[1].1, json!({"indices": [0], "art": true, "lockedAndHidden": false})); assert_eq!(action[2].1["commands"][0]["params"], json!({"scaleArt": false})); } @@ -329,14 +344,14 @@ fn artboard_commands_read_the_recorded_selected_artboards() { } /// `edit.copy` and `edit.cut` run the Artboard tool's copy and take no artboards, so their entries -/// stay as given. +/// stay as given, apart from the locked-art preference the Artboard tool's copy notes. #[test] fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() { for id in ["edit.copy", "edit.cut"] { let mut s = Session::new(); second_artboard_selected(&mut s); s.execute(id, &json!({})).unwrap(); - assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}"); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({"lockedAndHidden": false})), "{id}"); } } diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 706f40940..b4472c668 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 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/docs/mcp.md b/docs/mcp.md index fe2d705ca..9d57428b3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1168,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. @@ -3489,7 +3489,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) @@ -3943,8 +3944,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 @@ -3957,6 +3958,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. @@ -3964,8 +3968,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 @@ -4073,7 +4077,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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 184126541..906ea0be5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1179,6 +1179,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. From 489552c14b708fc370f6dbb8ce4b868e0497e980 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 16:21:46 -0800 Subject: [PATCH 08/52] Simplify: the slider's Escape test previews level 0, which changes the path The test's path zigzags by 0.1 pt. At level 100 the tolerance is 1/32 pt, and a fit now stays within it, so the preview at that level leaves the path as it was. Level 0's 32 pt tolerance takes the zigzag out. --- crates/ui-egui/src/dialogs/tests_modal.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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() }; From 53f6aafe87ade4fb5aad8de00a1efd4b480834ee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 14:25:59 -0800 Subject: [PATCH 09/52] .ai import: type on a path keeps its effect, alignment and flip Type on a path from a file's text document opened as Rainbow on its baseline, not flipped, whatever Type on a Path Options the frame kept. Each frame now keeps its effect (Rainbow, Skew, 3D Ribbon, Stair Step, Gravity), Align to Path (Ascender, Descender, Center, Baseline) and Flip. A code or flag that can't be read leaves that option at its default. Flipped type opens with the frame's path reversed once, after a copied object takes its own art path, and an open path's end bracket stays so the type starts where the file's range ends. Spacing is still fitted from the file's layout. The fit now measures along the path when Align to Path moves the baselines off it, and it follows the glyphs' feet under 3D Ribbon, whose baselines stay level. --- crates/engine/tests/import_fuzz.rs | 42 ++++ crates/eps/src/import/ate.rs | 307 +++++++++++++++++++++++--- crates/eps/src/import/native/plan.rs | 1 + crates/eps/src/import/tests_native.rs | 109 ++++++++- docs/file-format-parity.md | 1 + 5 files changed, 428 insertions(+), 32 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4eae16b21..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] diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 1e2347249..24a57cfa1 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,11 +763,21 @@ 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 }; @@ -743,12 +795,18 @@ 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 }; + let (along, next) = if effect == PathEffect::Ribbon3d { + (kurbo::Vec2::new(foot.y, -foot.x), kurbo::Vec2::new(next_foot.y, -next_foot.x)) + } else { + (*along, *next) + }; + let turn = next.dot(*foot).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. let half = turn.abs() * 0.5; - let length = if half > 1e-9 { (*q - *p).hypot() * half / half.sin() } else { (*q - *p).hypot() }; + let chord = (*q - *p).hypot(); + let length = if half > 1e-9 { chord * half / half.sin() } else { chord } + turn * offset; if turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() { sum += ((a0 + a1) * 0.5 - length) * turn; turns += turn * turn; @@ -850,11 +908,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 +972,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 +986,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 +1316,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 +1357,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 +1552,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 +1568,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 +1592,59 @@ 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}"); + } + + /// 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}"); } /// The leading of the text object a story comes in as. 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..f52a8baa0 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3857,6 +3857,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 +4170,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 +4190,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 +4250,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 +4517,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/docs/file-format-parity.md b/docs/file-format-parity.md index 21c35d24f..862c90856 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -164,6 +164,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). 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. From bc19dd64db458f9b6d6a57c029b73d4ac965fcdc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 18:23:58 -0800 Subject: [PATCH 10/52] Simplify: Convert to Straight Lines keeps the tip of a path that turns back past its end `rdp` measured each sample to the infinite line through the ends of the line that would replace it. A sample beyond either end could be far from that line segment and still be dropped: a line to (100, 0), a 0.2 pt U-turn and a line back to (50, 0.2) became one line at a tolerance of 1 pt, leaving the tip 50 pt away. `rdp` now measures to the segment, so a sample beyond an end is measured to that end, and a zero-length chord measures to its point as before. The test `a_path_that_turns_back_past_its_end_converted_to_straight_lines_keeps_its_tip` covers that path. --- crates/pathops/src/edit.rs | 7 ++++--- crates/pathops/src/fit.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 338900041..80f0deffd 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -476,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(); @@ -498,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); } diff --git a/crates/pathops/src/fit.rs b/crates/pathops/src/fit.rs index 4443e5b74..6b1f4eb0c 100644 --- a/crates/pathops/src/fit.rs +++ b/crates/pathops/src/fit.rs @@ -465,6 +465,23 @@ mod tests { } } + #[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)] { From bc5eb7aa471b89e119026a6d2d4ddabe0d545f26 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 16:11:52 -0800 Subject: [PATCH 11/52] Docs: state the limits of .ai type on a path import Align to Path takes its distance from the path from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout. --- docs/file-format-parity.md | 1 + docs/gaps.md | 2 +- docs/target-app-parity.md | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 862c90856..b4860e6c4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -164,6 +164,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). 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/target-app-parity.md b/docs/target-app-parity.md index 649781f13..1aef04833 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) | From 2faec2fa113ad2444b8ad95d367a4dff72ad3cd7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:27:31 -0800 Subject: [PATCH 12/52] .ai import: Skew, Stair Step and Gravity measure the path's turn square to it The spacing fitted from a type-on-a-path layout measured each turn against the glyph's own foot. Skew's feet stay upright, so on a tilted or leftward path the turn picked up a false angle and the fitted spacing was wrong (a straight path at 30 degrees gave -9.7 instead of 0). Effects other than Rainbow and 3D Ribbon now measure the turn against the baseline turned a right angle. The file-format doc states that a frame whose options set key 6 to true keeps the default effect, alignment and Flip. --- crates/eps/src/import/ate.rs | 67 +++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 2 ++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 24a57cfa1..274bbfaf9 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -801,7 +801,10 @@ fn path_spacing(f: &Val, effect: PathEffect) -> f64 { } else { (*along, *next) }; - let turn = next.dot(*foot).atan2(next.dot(along)); + // 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 } 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. let half = turn.abs() * 0.5; @@ -1638,6 +1641,68 @@ mod tests { 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 (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![0.0]); + for (i, (a, (centre, along))) in advances.iter().zip(glyphs).enumerate() { + xs.push(centre.x - along.x * a * 0.5); + ys.push(centre.y - along.y * a * 0.5); + matrices.extend([along.x, along.y, 0.0, 1.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 [ {} ] >> >> ] >> ] >> ] >>", + 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() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b4860e6c4..f91606b5b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -164,6 +164,8 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; 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. From 2fbd60a6dd6382bbfbdeb573591592de738c50c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 20:09:53 -0800 Subject: [PATCH 13/52] .ai import: 3D Ribbon spacing holds where the path turns back 3D Ribbon's level baseline faces the way the path runs, so where the path runs left its baseline and foot are turned round. The spacing fit read that as a half turn and left the pair out, so a short run on either side of a vertical tangent came in with no spacing. The fit now turns those glyphs back first. With an alignment, the two glyphs either side of that point lie on opposite sides of the path, and that pair is left out. --- crates/eps/src/import/ate.rs | 78 +++++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 274bbfaf9..1d16cb588 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -796,21 +796,29 @@ fn path_spacing(f: &Val, effect: PathEffect) -> f64 { .collect(); for w in glyphs.windows(2) { 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), kurbo::Vec2::new(next_foot.y, -next_foot.x)) + (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 } else { kurbo::Vec2::new(-along.y, along.x) }; + 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 chord = (*q - *p).hypot(); - let length = if half > 1e-9 { chord * half / half.sin() } else { chord } + turn * offset; - if turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() { + 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; } @@ -1685,16 +1693,22 @@ mod tests { /// 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, along))) in advances.iter().zip(glyphs).enumerate() { - xs.push(centre.x - along.x * a * 0.5); - ys.push(centre.y - along.y * a * 0.5); - matrices.extend([along.x, along.y, 0.0, 1.0, 0.0, 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 /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), @@ -1712,6 +1726,50 @@ mod tests { 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}"); + } + + /// 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. fn leading_of(story: &Story) -> Option { let node = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY)?; From cbfca36c8527409386697b3eeedd0d0ddeb6ed77 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:47:16 -0800 Subject: [PATCH 14/52] .ai import: spot colors in a kept brush's art become swatches The reader kept a brush's art but dropped the colors it read from it, so a spot color that only a Scatter or Art brush's art used made no swatch, and the art's process colors did not count toward the document's color mode. The reader now keeps each brush art section's colors with its art. When the document is finished, the colors of the art that a kept brush uses are added after the rest of the file's, in the order the art was read: its spot colors make swatches and its colors count toward the color mode. Art that no kept brush uses (a pattern brush's tiles, a section that a later one of the same name replaced, a section inside a symbol's definition) brings none of its colors, and a spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Part of #637. --- crates/eps/src/import/ai/read.rs | 5 ++- crates/eps/src/import/ai/read/directives.rs | 10 +++-- crates/eps/src/import/ai/read/document.rs | 21 ++++++++- crates/eps/src/import/ai/read/types.rs | 14 +++++- crates/eps/src/import/ai/tests.rs | 50 +++++++++++++++++++++ docs/file-format-parity.md | 1 + 6 files changed, 95 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index f96784543..e47a79460 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -139,7 +139,7 @@ pub use document::{ }; 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}; @@ -587,6 +587,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. @@ -737,6 +739,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/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 33af13372..ac51d7c28 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> { @@ -714,7 +714,9 @@ 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`]). 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 +762,9 @@ 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 colors = BrushArtColors { spots: sub.spots, stop_spots: sub.stop_spots, cmyk: sub.cmyk_colors, rgb: sub.rgb_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 106c07dd0..c476cecc0 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -946,6 +946,22 @@ 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. + 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(); + for (_, name) in names { + let Some(colors) = self.brush_art_colors.remove(&name) else { continue }; + self.spots.extend(colors.spots); + self.stop_spots.extend(colors.stop_spots); + self.cmyk_colors += colors.cmyk; + self.rgb_colors += colors.rgb; + } + } + /// 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. @@ -1056,6 +1072,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()); @@ -1076,7 +1096,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/types.rs b/crates/eps/src/import/ai/read/types.rs index 17f843d9a..0aa5c994a 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,18 @@ 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, +} + /// 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..6117f08e6 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7797,6 +7797,56 @@ 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()); +} + +/// 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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f91606b5b..a8eba07ab 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -164,6 +164,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). 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. From d180ad456ebca3473a7e501a9d017b659aaed65f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 17:31:14 -0800 Subject: [PATCH 15/52] .ai import: brush art brings at most 20,000 color names, each once Each brush art section keeps only the first definition of each color name, and at most 20,000 names. The art of the brushes that come in brings at most 20,000 different color names in all, every section's color operators before any section's gradient stops, so art that no brush uses or that a later section replaces takes no places. Colors left out still count toward the color mode. A test also checks that a global process color which only a brush's art defines with its color operator stays a process color when a gradient stop names it. --- crates/eps/src/import/ai/brush.rs | 3 + crates/eps/src/import/ai/read/directives.rs | 6 +- crates/eps/src/import/ai/read/document.rs | 25 ++++++++- crates/eps/src/import/ai/read/types.rs | 11 ++++ crates/eps/src/import/ai/tests.rs | 62 +++++++++++++++++++++ 5 files changed, 102 insertions(+), 5 deletions(-) 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/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index ac51d7c28..aedb753d6 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -716,7 +716,8 @@ impl<'a> Reader<'a> { /// 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. 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`]). + /// ([`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; @@ -763,7 +764,8 @@ impl<'a> Reader<'a> { }; let Some(node) = node else { return }; self.brush_art.insert(name.clone(), node); - let colors = BrushArtColors { spots: sub.spots, stop_spots: sub.stop_spots, cmyk: sub.cmyk_colors, rgb: sub.rgb_colors }; + 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); } diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index c476cecc0..084f4d4b6 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -948,18 +948,37 @@ impl<'a> Reader<'a> { /// 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 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 }; - self.spots.extend(colors.spots); - self.stop_spots.extend(colors.stop_spots); + 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 diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 0aa5c994a..d47805e97 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -261,6 +261,17 @@ pub(super) struct BrushArtColors { 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 6117f08e6..b4e99d402 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7836,6 +7836,68 @@ fn a_brushs_art_colors_come_after_the_pages_and_a_replaced_sections_bring_nothin 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() { From 77b72da70dde3cfa1a25d3c0011f26ccc89817e3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 20:23:14 -0800 Subject: [PATCH 16/52] .ai import: Stair Step spacing is fitted from its glyphs' origins Stair Step's glyphs stay upright, so their matrices never show the path turning and the spacing fit found no turn at all. The fit now takes the path through each glyph's origin, which lies on the path, and measures how far it turns over a step from the circle through three origins in a row. --- crates/eps/src/import/ate.rs | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 1d16cb588..bb96b3d27 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -783,6 +783,12 @@ fn path_spacing(f: &Val, effect: PathEffect) -> f64 { 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) @@ -828,6 +834,45 @@ fn path_spacing(f: &Val, effect: PathEffect) -> 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 { @@ -1747,6 +1792,29 @@ mod tests { 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. From 8a34c89d7ceb38f2bb72f8c0f32504b42990ec94 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:30:12 -0800 Subject: [PATCH 17/52] .ai import: remove the duplicated editing-copy paragraph --- docs/file-format-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a8eba07ab..cfaa5f377 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -164,6 +164,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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. From 57dbbbb41bbaa8ac875588c78d4f8465fd812e9c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 20:07:30 -0800 Subject: [PATCH 18/52] Docs: .ai brush art spot color limits The file-format entry states the 20,000-name limit across brush art and per section, the first-definition rule, and that colors past the limit still count toward the color mode. --- docs/file-format-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cfaa5f377..5b1976b41 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -164,6 +164,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic 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. From fe0f3d919c80d4ebd4aeac579464e62462441a9d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 11:05:24 +0700 Subject: [PATCH 19/52] .ai save: the editing copy keeps variables, data sets and bindings Squashed from #6024 onto main. --- crates/eps/src/ai_editing.rs | 28 +- crates/eps/src/ai_editing/tests.rs | 276 +++++++++++++ crates/eps/src/ai_editing/writer.rs | 35 +- crates/eps/src/ai_editing/writer/images.rs | 6 +- crates/eps/src/ai_editing/writer/variables.rs | 372 ++++++++++++++++++ docs/file-format-parity.md | 11 + docs/target-app-parity.md | 1 + 7 files changed, 721 insertions(+), 8 deletions(-) create mode 100644 crates/eps/src/ai_editing/writer/variables.rs 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 06fa1dbd2..8043b499e 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4152,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/docs/file-format-parity.md b/docs/file-format-parity.md index 5b1976b41..fc17be4a0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -91,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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1aef04833..4d397eee3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1223,6 +1223,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`). From 7d1f2062e33d1e134d6462520e2e7d153206ef86 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 11:05:33 +0700 Subject: [PATCH 20/52] Actions: Insert Stop pauses playback and shows a message Squashed from #6028 onto main. --- crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/dialogs/action_batch.rs | 6 + crates/ui-egui/src/dialogs/action_stop.rs | 107 +++ crates/ui-egui/src/i18n/bg.tsv | 13 + crates/ui-egui/src/i18n/bs.tsv | 13 + crates/ui-egui/src/i18n/ca.tsv | 13 + crates/ui-egui/src/i18n/cs.tsv | 13 + crates/ui-egui/src/i18n/da.tsv | 13 + crates/ui-egui/src/i18n/de.tsv | 13 + crates/ui-egui/src/i18n/el.tsv | 13 + crates/ui-egui/src/i18n/es.tsv | 13 + crates/ui-egui/src/i18n/fi.tsv | 13 + crates/ui-egui/src/i18n/fr.tsv | 13 + crates/ui-egui/src/i18n/hi.tsv | 13 + crates/ui-egui/src/i18n/hu.tsv | 13 + crates/ui-egui/src/i18n/id.tsv | 13 + crates/ui-egui/src/i18n/it.tsv | 13 + crates/ui-egui/src/i18n/ja.tsv | 13 + crates/ui-egui/src/i18n/ka.tsv | 13 + crates/ui-egui/src/i18n/ko.tsv | 13 + crates/ui-egui/src/i18n/nl.tsv | 13 + crates/ui-egui/src/i18n/pl.tsv | 13 + crates/ui-egui/src/i18n/pt-br.tsv | 13 + crates/ui-egui/src/i18n/ru.tsv | 13 + crates/ui-egui/src/i18n/sv.tsv | 13 + crates/ui-egui/src/i18n/tr.tsv | 13 + crates/ui-egui/src/i18n/uk.tsv | 13 + crates/ui-egui/src/i18n/vi.tsv | 13 + crates/ui-egui/src/i18n/zh-hans.tsv | 13 + crates/ui-egui/src/i18n/zh-hant.tsv | 13 + crates/ui-egui/src/menus/run.rs | 3 + crates/ui-egui/src/menus/ui_commands.rs | 22 +- crates/ui-egui/src/panels/actions.rs | 978 ++++++++++++++++++++- crates/ui-egui/src/panels/actions/files.rs | 34 +- docs/mcp.md | 42 +- docs/target-app-parity.md | 1 + 36 files changed, 1508 insertions(+), 39 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/action_stop.rs 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 fc7c7ceb3..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()); 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/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..3cf0f6d32 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 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/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/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 58ed08493..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,6 +244,9 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result Result Result, 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); @@ -2304,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", ), ] { @@ -3318,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/docs/mcp.md b/docs/mcp.md index aeb3c12e6..8ddd96d57 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1852,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 @@ -1899,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 @@ -1909,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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4d397eee3..bb928bea3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -632,6 +632,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. From a386b3e955dc654161db9dbe01f5dd4a28927e83 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 17:17:27 -0800 Subject: [PATCH 21/52] Color fields: a pasted hex code replaces the field's digits A click put the caret among the six digits and the text box holds seven characters, so only the first character of a pasted code went in. The field then held no new color, and Enter closed the dialog with the old color. A press on a field drawn before the hex field also dropped what was typed, while Tab and Enter applied it. The hex fields of the Color Picker, Swatch Options, Recolor Artwork, the Color panel, Document Setup, Duotone, Color Themes and Perspective Grid now share one set of rules: - A click or keyboard focus selects the digits. - A code of six digits, with or without #, replaces the digits wherever the caret is. - A 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 paste of only one, two, four or five digits edits the text in place when it does not cover all of the text and the result has at most six digits. - Any other paste leaves the field as it was. - A press elsewhere commits the text in the frame of the press. - A commit returns the digits without # and writes a three-digit code out as six, so Duotone reads a typed # and Perspective Grid, whose parser reads six digits only, takes a three-digit code. Part of #5013 --- crates/ui-egui/src/dialogs/swatch_options.rs | 50 +++--- crates/ui-egui/src/dialogs/tests.rs | 105 +++++++++++ .../ui-egui/src/dialogs/tests_perspective.rs | 42 +++++ crates/ui-egui/src/tests/numfields.rs | 100 +++++++++++ crates/ui-egui/src/widgets.rs | 2 +- crates/ui-egui/src/widgets/color.rs | 165 ++++++++++++++---- docs/target-app-parity.md | 1 + 7 files changed, 405 insertions(+), 60 deletions(-) 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..bdc4f0b44 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -952,6 +952,111 @@ 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() }; + 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)]); + 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)]); + 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_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs index c88f79613..f83613913 100644 --- a/crates/ui-egui/src/dialogs/tests_perspective.rs +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -71,6 +71,48 @@ 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() }; + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, 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.clone()]); + 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/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/widgets.rs b/crates/ui-egui/src/widgets.rs index 5a6daf1fa..e3972c8aa 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, diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index cb44bfe04..456a1bfbc 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,153 @@ 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. Any other code replaces the field's text, and any other +/// paste is ignored. +fn hex_paste(text: &str, kept: usize, whole: 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 && 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, mut 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 mut replaced = false; + ui.input_mut(|i| { + i.events.retain(|e| { + let egui::Event::Paste(text) = e else { return true }; + let len = buf.chars().count(); + let hash = buf.starts_with('#') && start > 0; + match hex_paste(text, len.saturating_sub(selected + usize::from(hash)), selected > 0 && selected >= len) { + HexPaste::Insert => return true, + HexPaste::Replace(digits) => { + *buf = digits; + selected = 0; + replaced = true; + } + HexPaste::Ignore => {} + } + false + }); + }); + if replaced { + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(buf.len())))); + state.store(ui.ctx(), id); + } + 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`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb928bea3..6ae62e897 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1112,6 +1112,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. From 0b6a6d44c699618184aa32c8f904e9d9efb58bf0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 20:49:17 -0800 Subject: [PATCH 22/52] Color fields: a hex paste never goes before a leading #, and a replacing paste drops earlier inserts With the caret before a # that stays, bare digits are not inserted ahead of it; a full code replaces the text instead. A paste that replaces the field's digits also removes bare-digit pastes kept earlier in the same frame. --- crates/ui-egui/src/widgets/color.rs | 112 ++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 456a1bfbc..72f74f7b9 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -477,14 +477,15 @@ enum HexPaste { /// 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. Any other code replaces the field's text, and any other -/// paste is ignored. -fn hex_paste(text: &str, kept: usize, whole: bool) -> HexPaste { +/// 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 && kept + digits.len() <= 6 && (digits.len() != 3 || kept == 3) { + } 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()) @@ -500,28 +501,11 @@ fn take_hex_paste(ui: &Ui, id: egui::Id, buf: &mut String) -> bool { return false; } let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let (start, mut selected) = state.cursor.char_range().map_or((0, 0), |r| { + 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 mut replaced = false; - ui.input_mut(|i| { - i.events.retain(|e| { - let egui::Event::Paste(text) = e else { return true }; - let len = buf.chars().count(); - let hash = buf.starts_with('#') && start > 0; - match hex_paste(text, len.saturating_sub(selected + usize::from(hash)), selected > 0 && selected >= len) { - HexPaste::Insert => return true, - HexPaste::Replace(digits) => { - *buf = digits; - selected = 0; - replaced = true; - } - HexPaste::Ignore => {} - } - false - }); - }); + 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); @@ -529,6 +513,36 @@ fn take_hex_paste(ui: &Ui, id: egui::Id, buf: &mut String) -> bool { 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()) { @@ -756,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"); + } +} From 97df62547b2b8e2d4ebe84d9695983fe93f250f0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 23:17:56 -0800 Subject: [PATCH 23/52] Tests: the hex paste dialog tests release Enter after each press A dialog's OK ignores a repeat of Enter, and an egui context keeps a key down until it is released. The Color Picker, Swatch Options and Define Grid tests pressed Enter in one context without releasing it, so every press after the first was a repeat and the dialog stayed open. Each test now releases Enter after pressing it. --- crates/ui-egui/src/dialogs/tests.rs | 4 ++++ crates/ui-egui/src/dialogs/tests_perspective.rs | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index bdc4f0b44..555b21d1f 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -978,6 +978,8 @@ fn a_hex_code_pasted_into_the_hex_field_replaces_its_digits() { 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; @@ -1024,6 +1026,7 @@ fn a_hex_code_pasted_into_the_hex_field_replaces_its_digits() { 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"); } @@ -1050,6 +1053,7 @@ fn a_hex_code_pasted_into_the_hex_field_replaces_its_digits() { 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(); diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs index f83613913..97255bd22 100644 --- a/crates/ui-egui/src/dialogs/tests_perspective.rs +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -94,7 +94,8 @@ fn define_grid_takes_a_three_digit_color() { texts }; let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let enter = egui::Event::Key { key: egui::Key::Enter, 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 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")); @@ -107,7 +108,8 @@ fn define_grid_takes_a_three_digit_color() { frame(&mut app, vec![button(at, false)]); frame(&mut app, vec![]); frame(&mut app, vec![input]); - frame(&mut app, vec![enter.clone()]); + 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"); } From 6ab82ca71e9b6c0cc568637dfa21f6dc3c313d44 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 16:21:36 -0800 Subject: [PATCH 24/52] .ai import: a filled clipping path keeps its fill The reader took the fill off a clipping path in a file's editing data and added a copy of the path, holding only that fill, under the art it clips. A clipping path paints its own fill under the clipped art and its stroke over it, so the reader now keeps the fill on the clipping path. A filled clipping path opens as one path instead of two, in a clipping group, a clipping layer and each level of nested clipping paths. Its note, tags and gradient stroke (`1 BB`) are on that path as before. A compound clipping path whose subpaths all paint alike keeps their fill. When its subpaths paint differently, the fills of the filled subpaths are still a compound path of their own under the clipped art, so a subpath the file does not fill stays unfilled. Clipping paths in a compound path that holds its drawn look still have their fills read as paths of their own under the art, each with its clipping path's opacity mask. Docs: docs/file-format-parity.md. --- crates/eps/src/ai_editing/tests.rs | 14 +- crates/eps/src/import/ai/read.rs | 3 +- crates/eps/src/import/ai/read/art.rs | 41 +----- crates/eps/src/import/ai/read/containers.rs | 64 ++++++--- crates/eps/src/import/ai/read/ops.rs | 4 +- crates/eps/src/import/ai/tests.rs | 137 ++++++++++++++------ crates/eps/src/import/tests_native.rs | 7 +- docs/file-format-parity.md | 2 + 8 files changed, 170 insertions(+), 102 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 8043b499e..e4a1edff5 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1396,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); @@ -1421,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) ] ); @@ -2090,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); @@ -2107,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); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index e47a79460..4dbf31a24 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -515,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, 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..5667ebd0f 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -147,10 +147,15 @@ 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 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_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 +233,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 +243,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 +256,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 +286,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 +314,37 @@ 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. + 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); + (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); + if let Some(mask) = n.extras().mask.clone() { + 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(); + 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/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/tests.rs b/crates/eps/src/import/ai/tests.rs index b4e99d402..3eec2dbee 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:?}"); @@ -1245,20 +1246,61 @@ 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 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 +1321,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 +1367,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 +1399,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 +1802,31 @@ 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!("1 g\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 a = art(&read_ok(&layer(&body)).doc); + let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{a:#?}") }; + children.iter().map(|c| (c.kind.clone(), fill(c).color(), c.extras().mask.is_some())).collect::>() + }; + let (red, blue) = (Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))); + let paths = |c: &[(NodeKind, Option, bool)]| { + 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)).collect::>() + }; + assert_eq!(paths(&children("", "")), [(false, None, false), (true, red, false), (true, red, false)]); + assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", "")), [(false, None, false), (true, red, false), (true, blue, false)]); + assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", &mask)), [(false, None, false), (true, red, true), (true, blue, false)]); +} + #[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 @@ -3700,7 +3761,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 +3770,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 +5265,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 +5276,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] @@ -8664,13 +8723,13 @@ 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, under what the mask covers; it keeps its fill. 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]"); + assert_eq!(layer_trees(&doc)[0].1, "k[c 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]).color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the clipping path's fill"); // 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}", diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index f52a8baa0..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")); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc17be4a0..9045522c6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -175,6 +175,8 @@ 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; 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. From 72959e6e80c88f0d7cb158590a4181ac15ad535a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 18:15:47 -0800 Subject: [PATCH 25/52] .ai import: a multi-layer mask's fill paints once Each layer a multi-layer mask covers gets a copy of the mask's path, so a fill kept on that path painted in every layer, over the art the mask covers in the layers before. The mask's fill is read as a path of its own again, where the mask's path is, and the copies have no fill. A fill read apart from a compound clipping path is a compound path whose subpaths do not clip and have ids of their own. A fill read apart, its subpaths and their points count toward the importer's object limit. Docs: docs/file-format-parity.md. --- crates/eps/src/import/ai/read/containers.rs | 18 +++++++- crates/eps/src/import/ai/read/masks.rs | 27 ++++++++--- crates/eps/src/import/ai/tests.rs | 50 +++++++++++++++++++-- docs/file-format-parity.md | 2 + 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 5667ebd0f..abe4eaf27 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -322,7 +322,7 @@ impl<'a> Reader<'a> { /// 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. - fn fill_apart(&mut self, n: &mut Node) -> Option { + 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; @@ -332,6 +332,14 @@ impl<'a> Reader<'a> { *clipping = false; } let mut fill = Node::new(self.doc.alloc_id(), kind); + // A compound clipping path's subpaths: copies of their own that don't clip. + for c in fill.children_mut().into_iter().flatten() { + let c = Arc::make_mut(c); + self.reid(c); + if let NodeKind::Path { clipping, .. } = &mut c.kind { + *clipping = false; + } + } (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); if let Some(mask) = n.extras().mask.clone() { @@ -342,6 +350,14 @@ impl<'a> Reader<'a> { 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) } 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/tests.rs b/crates/eps/src/import/ai/tests.rs index 3eec2dbee..0cb243692 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -8723,13 +8723,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; it keeps its fill. + // `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]"); + 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[0]).color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the clipping path's fill"); + 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}", @@ -8739,6 +8757,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. @@ -8821,4 +8861,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/docs/file-format-parity.md b/docs/file-format-parity.md index 9045522c6..dea737da7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -175,6 +175,8 @@ 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; 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. From b33cdfd14feffbbbcf3983ee0fe15cbf7f7822a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 18:37:12 -0800 Subject: [PATCH 26/52] .ai import: a clipping path's fill read apart keeps its own ids A fill read apart from a clipping path took a copy of the clipping path's opacity mask whose art kept the original's ids, so the document held two objects with one id. The copy of the mask's art, and the subpaths of a fill read apart from a compound clipping path, now get ids of their own and stay linked to the same swatches. --- crates/eps/src/import/ai/read/containers.rs | 15 ++++--- crates/eps/src/import/ai/read/directives.rs | 4 +- crates/eps/src/import/ai/tests.rs | 46 +++++++++++++++++---- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index abe4eaf27..e2fc2ac8b 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -332,17 +332,22 @@ impl<'a> Reader<'a> { *clipping = false; } let mut fill = Node::new(self.doc.alloc_id(), kind); - // A compound clipping path's subpaths: copies of their own that don't clip. + // 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 c = Arc::make_mut(c); - self.reid(c); - if let NodeKind::Path { clipping, .. } = &mut c.kind { + let mut copy = self.doc.reid(c); + self.reid_masks(&mut copy, 0); + if let NodeKind::Path { clipping, .. } = &mut copy.kind { *clipping = false; } + *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); - if let Some(mask) = n.extras().mask.clone() { + // 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()); diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index aedb753d6..307105f95 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -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; } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 0cb243692..f85b67e0c 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1809,22 +1809,52 @@ fn a_compound_path_holding_its_drawn_look_paints_its_paths_apart() { 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!("1 g\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)).lines().map(|l| format!("%_{l}\n")).collect(); + 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 a = art(&read_ok(&layer(&body)).doc); + 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:#?}") }; - children.iter().map(|c| (c.kind.clone(), fill(c).color(), c.extras().mask.is_some())).collect::>() + // 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))); - let paths = |c: &[(NodeKind, Option, bool)]| { + // 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)).collect::>() + c[..3].iter().map(|(k, c, m)| (matches!(k, NodeKind::Path { clipping: false, .. }), *c, m.clone())).collect::>() }; - assert_eq!(paths(&children("", "")), [(false, None, false), (true, red, false), (true, red, false)]); - assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", "")), [(false, None, false), (true, red, false), (true, blue, false)]); - assert_eq!(paths(&children("0 0 0 0 0 0 1 Xa\n", &mask)), [(false, None, false), (true, red, true), (true, blue, false)]); + 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] From 589fd863c46d902c24c0ce403b04e82c4db9c2fe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:11:35 -0800 Subject: [PATCH 27/52] .ai import: a compound path's plain subpath keeps its fill In a compound clipping path whose subpaths paint differently, the fill of a subpath that does not clip was read apart with the clipping subpaths' fills. Only the clipping subpaths' fills are read apart now, and a subpath that does not clip keeps its fill, as before. --- crates/eps/src/import/ai/read/containers.rs | 8 ++++++-- crates/eps/src/import/ai/tests.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index e2fc2ac8b..0f5df81bd 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -150,10 +150,14 @@ impl<'a> Reader<'a> { // 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 ones are a compound path of their own, under what it clips. + // 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_map(|k| self.fill_apart(k)).collect(); + 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 { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index f85b67e0c..579f7967a 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1293,6 +1293,21 @@ fn compound_paths_and_clipping() { 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") }; From bbf4834f12013429418feff3adcd99ee89cb30b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 20:50:55 -0800 Subject: [PATCH 28/52] .ai import: a hidden clipping path's fill leaves with it The fill read apart from a clipping path that was read on %_ lines, and the copies of its subpaths, are recorded as read on those lines too, so the copy of a look's art that is removed takes them along. The file format notes list a compound clipping path whose subpaths paint differently among the exceptions to a clipping path keeping its fill. --- crates/eps/src/import/ai/read/containers.rs | 8 ++++++++ crates/eps/src/import/ai/tests.rs | 22 +++++++++++++++++++++ docs/file-format-parity.md | 2 ++ 3 files changed, 32 insertions(+) diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 0f5df81bd..a08f2ea1c 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -336,6 +336,11 @@ impl<'a> Reader<'a> { *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); @@ -343,6 +348,9 @@ impl<'a> Reader<'a> { 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) = diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 579f7967a..fd1e2ece3 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -375,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] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dea737da7..14a233f4b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -175,6 +175,8 @@ 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. From bc9ea516f5eabcd148e6f8a0c93f7bd0589d25bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 17:05:31 -0800 Subject: [PATCH 29/52] Dimension tool: Expand keeps a dimension as plain art after it moves Object > Expand left a dimension's recipe on its group, so the next move redrew the live dimension and discarded the expanded art. Expand now ends the live link of a dimension whose art it changes, in the same undo step. Moving or scaling the group leaves its art as it is, the art no longer follows what it measured, and undo gives the live dimension back. Expand Appearance, Create Outlines and Outline Stroke had the same fault and now do the same, also when they reach only a part of a dimension. A dimension whose art a command leaves as it was stays live. A dimension selected with the art it measures becomes a plain group when Expand, Expand Appearance or Outline Stroke replaces only that art. --- crates/engine/src/cmd/dimension.rs | 58 +++++ crates/engine/src/cmd/effectcmd.rs | 7 +- crates/engine/src/cmd/expand.rs | 32 ++- crates/engine/src/cmd/pathops.rs | 7 +- crates/engine/src/cmd/typecmd.rs | 4 +- crates/engine/tests/it/dimensions.rs | 342 +++++++++++++++++++++++++++ docs/dimensions.md | 10 +- docs/mcp.md | 2 + docs/target-app-parity.md | 1 + 9 files changed, 455 insertions(+), 8 deletions(-) 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/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index a823ee161..b8f614858 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 whose measured art it replaces, 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..4c2680120 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), any option that changes a dimension's art makes the dimension a plain group of that art, 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}", 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/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..e127d4e69 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 ), @@ -554,6 +554,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 +649,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/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/docs/dimensions.md b/docs/dimensions.md index b8ece996a..129800319 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -36,8 +36,14 @@ 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. A dimension +selected with the art it measures also becomes a plain group when Object › Expand, Object › Expand +Appearance or Object › Path › Outline Stroke replaces only that art. Pasted +dimensions keep their geometry and drop their links, so they never bind to unrelated art in another +document. ## Commands diff --git a/docs/mcp.md b/docs/mcp.md index 8ddd96d57..d3fe607e7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2972,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"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6ae62e897..070da6ecd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -708,6 +708,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. From 9e0e8993b992cb7894953de83d3cef2734cedcb7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:24:18 -0800 Subject: [PATCH 30/52] Dimension tool: state the Expand rule after object.expand's parameter list The sentence inside the braces made every_description_parses fail. The object.expand and effect.expandAppearance texts now also limit the replaced-art case to a dimension selected with that art. --- crates/engine/src/cmd/effectcmd.rs | 2 +- crates/engine/src/cmd/expand.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index b8f614858..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. A dimension whose art it changes, or whose measured art it replaces, 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)}", + "{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 ), diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 4c2680120..d67b89a97 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -43,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), any option that changes a dimension's art makes the dimension a plain group of that art, 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 ), From 09aa816394c9615297e1d8cd09dccf7c65b73a95 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:56:10 -0800 Subject: [PATCH 31/52] Docs: dimensions state when the menus dim Expand and when a dimension stays live --- docs/dimensions.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/dimensions.md b/docs/dimensions.md index 129800319..e55e698f2 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -39,9 +39,13 @@ ordinary paths and text, so exported dimensions look the same. Ungrouping a dime 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. A dimension -selected with the art it measures also becomes a plain group when Object › Expand, Object › Expand -Appearance or Object › Path › Outline Stroke replaces only that art. Pasted +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. From c952b8d298afaf6525ebc3eb759bb68e8d83be2f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 18:33:50 -0800 Subject: [PATCH 32/52] Fix #5347: a floating panel's menu scrolls inside its window The main window's menu floor was stored once for the whole egui context, so a menu in a panel's OS window stopped at the main window's bottom bars instead of its own edge. The floor is now kept per window, and a window with no recorded floor uses its bottom edge. A menu list is also never taller than the window, so the last items of a menu stay reachable in a 100 pt window, below the 120 pt minimum. The Digits, Stylistic Sets, Save Selection and Move to Group submenus, the libraries menu, the Color Guide's Limit to Library menu and the Layers panel's Filter popup now scroll the same way. --- .../ui-egui/src/panel_docking/native_tests.rs | 156 ++++++++++++++++++ crates/ui-egui/src/panels/character.rs | 2 +- crates/ui-egui/src/panels/color_guide.rs | 2 +- crates/ui-egui/src/panels/layers/controls.rs | 72 ++++---- crates/ui-egui/src/panels/libraries.rs | 38 +++-- crates/ui-egui/src/panels/library_panel.rs | 14 +- crates/ui-egui/src/panels/opentype.rs | 14 +- crates/ui-egui/src/widgets.rs | 35 +++- crates/ui-egui/src/widgets/dropdowns.rs | 39 +++-- docs/target-app-parity.md | 1 + 10 files changed, 294 insertions(+), 79 deletions(-) diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index a7c661404..7f52af524 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -321,6 +321,162 @@ 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).unwrap_or_else(|| panic!("the {row} row")).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).unwrap_or_else(|| panic!("the {row} submenu's item {first}")); + 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).unwrap_or_else(|| panic!("the {row} submenu's item {last}")); + assert!(end.bottom() <= height, "{last} at {end:?} is inside the {height} pt window"); +} + #[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/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/widgets.rs b/crates/ui-egui/src/widgets.rs index e3972c8aa..bb5bf4a72 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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/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/target-app-parity.md b/docs/target-app-parity.md index 070da6ecd..2ffd62a04 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -677,6 +677,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`). - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. + - A 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`). From 37245779aa9faa6e5f464e309e6f6cc2b9bd60e9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 20:00:58 -0800 Subject: [PATCH 33/52] .ai import: legacy overflow breaks keep the direction and paragraph attributes they were read with The reader holds an overflow paragraph break (`(\r) TX`) until more type follows it. When that type came, the break took the state in force then, not the state where the break was read: - A story that started with the break took the `TV` direction of the characters after it, so `1 TV (\r) TX 0 TV (Body) Tx` opened horizontal and gave no warning. - A break read in the other direction inside a story did not count as a direction change. - The empty paragraph that the break ended took the attributes of the paragraph after it, while `(\r) Tx` keeps its own. The pending break now records `TV` and, when it ends an empty paragraph, that paragraph's attributes. --- crates/eps/src/import/ai/legacy_text.rs | 30 ++++++++++++++++++++----- crates/eps/src/import/ai/tests.rs | 25 +++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) 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/tests.rs b/crates/eps/src/import/ai/tests.rs index fd1e2ece3..e736fe6da 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3554,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. From 2432807197ca5e2de7e0d60464a0b86662c922c5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:23:33 -0800 Subject: [PATCH 34/52] Tests: the short-window submenu helper names its rows with expect --- crates/ui-egui/src/panel_docking/native_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index 7f52af524..6ef2ab536 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -462,10 +462,10 @@ fn assert_submenu_reaches_its_end(h: &mut Harness, panel: &str, height: f32, row } wheel_down(h, viewport, pos2(menu.x - 60.0, height - 30.0), 40.0); } - let at = inside(h).unwrap_or_else(|| panic!("the {row} row")).center(); + 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).unwrap_or_else(|| panic!("the {row} submenu's item {first}")); + 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() { @@ -473,7 +473,7 @@ fn assert_submenu_reaches_its_end(h: &mut Harness, panel: &str, height: f32, row } wheel_down(h, viewport, pos2(over.center().x, height - 30.0), 5000.0); } - let end = text_rect(h, viewport, last).unwrap_or_else(|| panic!("the {row} submenu's item {last}")); + 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"); } From 7d8f2a315ada09e67f81615a8c16a1249ac2de69 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 19:44:44 -0800 Subject: [PATCH 35/52] Paste: skips relinking when nothing changes and finds pasted type in one walk A paste relinked every copy to the document's resources even when no resource had another name or color and the objects linked no graphic style. It now leaves those copies as they are. Finding the pasted type looked each id up and compared every text object with the ones already found. Text commands and the bounds refresh after a paste now look all ids up in one walk and keep the found ids in a set. Giving a pasted group new ids builds its map of old to new ids only for an Objects on Path group, which is the only kind that reads it. A pasted solid paint with no swatch link and a tint other than 1 now keeps that tint when no relinking runs. Saved art carries a tint of 1 on such a paint. --- crates/doc/src/document_guides.rs | 5 +- crates/engine/src/cmd/clipboard/resources.rs | 8 +- crates/engine/src/cmd/typecmd.rs | 17 +- crates/engine/src/cmd/typemenu.rs | 6 +- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/paste_many.rs | 209 +++++++++++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 crates/engine/tests/it/paste_many.rs 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/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index e127d4e69..26db35c3d 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -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); } }); 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/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/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/docs/target-app-parity.md b/docs/target-app-parity.md index e6f448a79..e7cd136eb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1398,6 +1398,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 of 20,000 rectangles in 200 groups takes 25.1 ms in a release build instead of 40.0 ms, and of 5,000 point type objects 9.8 ms instead of 14.4 ms (medians of five interleaved runs of 21 pastes each). 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. From 2f09d9998e4fc85359c4d7a8a860fe47b6c63ba7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 17:07:11 -0800 Subject: [PATCH 36/52] Menus: Delete Empty Artboards' enabled check does not walk the document every frame The Artboards panel menu asks whether Delete Empty Artboards can run on every frame while it is open, and each ask measured the document. With an open path of 200,000 anchors across two artboards, that took 70.4 ms a frame in a release build. The answer is now kept per open document with the version of the document it was measured on, and is measured again when the document changes. A later frame takes under 0.001 ms. The first frame after an edit measures once. A change of the selection or the view keeps the answer. --- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/doc_state.rs | 31 ++++++ crates/engine/src/tests/panelcmds.rs | 135 +++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 168 insertions(+), 1 deletion(-) 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/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/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index b4472c668..0f95fc2f6 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -1706,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/docs/target-app-parity.md b/docs/target-app-parity.md index 2ffd62a04..e35763702 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -559,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. From b17a744a9c0eaaba23ce8cd4d2fb2723cef3e8a2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 11:06:17 +0700 Subject: [PATCH 37/52] Panels: floating panels reopen on the monitor where they were left Squashed from #6038 onto main. --- apps/vectorcraft/src/app.rs | 7 ++ apps/vectorcraft/src/main.rs | 3 + apps/vectorcraft/src/window.rs | 85 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panel_docking.rs | 1 + .../ui-egui/src/panel_docking/native_tests.rs | 59 +++++++++++++ crates/ui-egui/src/services.rs | 5 ++ docs/target-app-parity.md | 1 + 8 files changed, 162 insertions(+) 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/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/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/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 6ef2ab536..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)); @@ -477,6 +481,61 @@ fn assert_submenu_reaches_its_end(h: &mut Harness, panel: &str, height: f32, row 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/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/docs/target-app-parity.md b/docs/target-app-parity.md index e35763702..e6f448a79 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -678,6 +678,7 @@ 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`). From a18fbee3c949f735b5262f98c2631707dc07be82 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 21:14:01 -0800 Subject: [PATCH 38/52] Docs: the paste timing line gives CPU time per paste and undo --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7cd136eb..7bf38edc6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1398,7 +1398,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 of 20,000 rectangles in 200 groups takes 25.1 ms in a release build instead of 40.0 ms, and of 5,000 point type objects 9.8 ms instead of 14.4 ms (medians of five interleaved runs of 21 pastes each). Tests: `crates/engine/tests/it/paste_many.rs`. +- **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. From 2af4c12dd37b5890e2598469bfc830806de1b716 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 21:01:11 -0800 Subject: [PATCH 39/52] i18n: the Turkish params and effect errors name their own keys ``params` must be an object` read as "gradyan" and `missing `effect`` read as "isim", the keys of two other rows. Both rows now keep the key in backticks, as the rows next to them do. --- crates/ui-egui/src/i18n/tr.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3cf0f6d32..7d6676c87 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5944,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ı From eb99aaabfd8dd080c6788e636c32b2e21945f1c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 21:15:47 -0800 Subject: [PATCH 40/52] Tests: the Mac menu bar tests do not depend on fonts other tests install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type › Font and the plug-in rows come from process-wide state, and a test running alongside can add to them. Two tests read the menus more than once and compared the reads, so a font added in between failed them: fonts_that_load_read_the_menus_again adds Vertitest Sans, for one. every_in_window_item_is_in_the_mac_menu_bar_once now lays out the Mac bar from the in-window bar it compares it with, so both come from one read. documents_and_selections_change_state_not_structure changes the document between its reads, so it runs through undisturbed, which runs it again when the shortcut, plug-in or font generations change. --- crates/ui-egui/src/tests/nativemenu.rs | 34 ++++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) 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] From 0b43158fbb67601c00790dddd65998573a207fa0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 21:33:24 -0800 Subject: [PATCH 41/52] Fix #5813: floating panels hide while a tool group's flyout is open A tool group's flyout and the All Tools drawer draw in the main window, so a floating panel's OS window covered them. On the desktop, except on Wayland, floating panels now hide while either one is open, as they do for a menu or a dialog, and show again when it closes. A flyout or drawer left set under a hidden toolbar does not draw and does not hide them. --- crates/ui-egui/src/edit_toolbar.rs | 3 + crates/ui-egui/src/panel_docking.rs | 4 +- .../ui-egui/src/panel_docking/native_tests.rs | 133 ++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 23 +++ docs/target-app-parity.md | 1 + 5 files changed, 162 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index eb79cf357..1a4c54989 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -598,6 +598,9 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { // it open. app.ui.tools_drawer = false; } + if app.ui.tools_drawer { + crate::toolbar::mark_flyout_open(ctx); + } ghost(ctx, &t); } diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index e2cb86d34..73d981000 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1136,9 +1136,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } let modal = modal_open(app, &ctx); // Palette windows are OS windows of their own, held above the app's window while it is active, - // so a dialog or a menu drawn in the main window would be covered by them. + // so a dialog, a menu or a tool flyout drawn in the main window would be covered by them. #[cfg(not(target_arch = "wasm32"))] - palette_window_levels(&ctx, &layout, modal, egui::Popup::is_any_open(&ctx) || crate::menus::menu_open(&ctx)); + palette_window_levels(&ctx, &layout, modal, egui::Popup::is_any_open(&ctx) || crate::menus::menu_open(&ctx) || crate::toolbar::flyout_open(&ctx)); let mut blocked = Vec::new(); if modal || egui::Popup::is_any_open(&ctx) { blocked.push(ctx.content_rect()); diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index 5b538b765..784b86fcf 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -651,6 +651,139 @@ fn a_menu_of_the_menu_bar_lowers_the_palette_windows() { "and is hidden until the menu closes" ); } + +/// The visibility and level commands the palette window `viewport` was sent, cleared after reading. +fn take_palette_commands(h: &Harness, viewport: ViewportId) -> (Vec, Vec) { + let commands = std::mem::take(&mut h.host.borrow_mut().commands); + let shown = commands + .iter() + .filter_map(|(id, command)| match command { + ViewportCommand::Visible(visible) if *id == viewport => Some(*visible), + _ => None, + }) + .collect(); + let levels = commands + .iter() + .filter_map(|(id, command)| match command { + ViewportCommand::WindowLevel(level) if *id == viewport => Some(*level), + _ => None, + }) + .collect(); + (shown, levels) +} + +/// Opens the flyout of the group `tools` on the Tools panel. +fn open_tool_flyout(h: &mut Harness, tools: &[&str]) { + h.app.ui.flyout = Some(0); + let tools: Vec = tools.iter().map(|tool| tool.to_string()).collect(); + h.ctx.data_mut(|d| { + d.insert_temp(egui::Id::unique("flyout-tools"), tools); + d.insert_temp(crate::toolbar::flyout_toolbar_id(), None::); + }); +} + +/// A tool group's flyout is drawn in the main window, so a palette window over it hides until it +/// closes (#5813), and takes its palette level again with the keyboard left where it was. +#[test] +fn a_tool_flyout_hides_the_palette_windows_until_it_closes() { + let mut h = Harness::new(); + let viewport = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, viewport); + open_tool_flyout(&mut h, &["pen", "curvature"]); + h.frames(2); + let (shown, levels) = take_palette_commands(&h, viewport); + assert_eq!(shown, [false], "hidden while the flyout is open"); + assert_eq!(levels, [egui::WindowLevel::Normal], "and out of the top level"); + assert_eq!(h.app.ui.flyout, Some(0), "the flyout stays open"); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport), (vec![], vec![]), "the commands go once"); + h.app.ui.flyout = None; + h.frames(2); + let focus_commands = h.host.borrow().commands.iter().filter(|(_, command)| matches!(command, ViewportCommand::Focus)).count(); + assert_eq!(focus_commands, 0, "nothing takes the keyboard"); + let (shown, levels) = take_palette_commands(&h, viewport); + assert_eq!(shown, [true], "shown again when the flyout closes"); + assert_eq!(levels, [egui::WindowLevel::AlwaysOnTop], "at its palette level"); +} + +/// The All Tools drawer is drawn in the main window too, so it hides a palette window the same way. +#[test] +fn the_all_tools_drawer_hides_the_palette_windows_until_it_closes() { + let mut h = Harness::new(); + let viewport = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, viewport); + h.app.run("window.editToolbar", json!({"open": true})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden while the drawer is open"); + h.app.run("window.editToolbar", json!({"open": false})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown again when the drawer closes"); +} + +/// A flyout or a drawer left set while its toolbar is hidden does not draw, so the palette windows +/// stay on screen. +#[test] +fn a_flyout_or_drawer_of_a_hidden_toolbar_leaves_the_palette_windows_shown() { + let mut h = Harness::new(); + let viewport = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, viewport); + h.app.run("window.editToolbar", json!({"open": true})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden under the drawer"); + h.app.run("window.toolbar", json!({})).unwrap(); + h.frames(1); + assert!(!h.app.ui.toolbar && h.app.ui.tools_drawer, "the drawer is left set with the Tools panel hidden"); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown again: the drawer does not draw"); + h.app.ui.tools_drawer = false; + h.app.run("window.toolbar", json!({})).unwrap(); + open_tool_flyout(&mut h, &["pen", "curvature"]); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden under the flyout"); + // Tab hides the toolbar and the panels, and Shift+Tab shows the panels alone. + h.app.run("window.dock", json!({})).unwrap(); + h.app.run("window.panels", json!({})).unwrap(); + h.frames(1); + assert!(!h.app.ui.toolbar && h.app.ui.flyout.is_some(), "the flyout is left set with the Tools panel hidden"); + assert_eq!(take_palette_commands(&h, viewport).0.last(), Some(&true), "shown again: the flyout does not draw"); +} + +/// The frame in which a click elsewhere closes a flyout or the drawer shows the palette windows, as +/// no later frame need come while the app is idle. +#[test] +fn a_click_elsewhere_shows_the_palette_windows_in_the_frame_it_closes_a_flyout() { + let mut h = Harness::new(); + let viewport = h.float("navigator"); + h.host.borrow_mut().focus = ViewportId::ROOT; + h.frames(2); + take_palette_commands(&h, viewport); + // On the dock: a press on the canvas closes a flyout later in its frame, and the release's frame + // shows the palette windows. + let at = pos2(1500.0, 700.0); + let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let click = |h: &mut Harness| { + h.frame(vec![Event::PointerMoved(at), button(true)]); + h.frame(vec![button(false)]); + }; + open_tool_flyout(&mut h, &["pen", "curvature"]); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden under the flyout"); + click(&mut h); + assert_eq!(h.app.ui.flyout, None, "the click closes the flyout"); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown in that frame"); + h.app.run("window.editToolbar", json!({"open": true})).unwrap(); + h.frames(2); + assert_eq!(take_palette_commands(&h, viewport).0, [false], "hidden under the drawer"); + click(&mut h); + assert!(!h.app.ui.tools_drawer, "the click closes the drawer"); + assert_eq!(take_palette_commands(&h, viewport).0, [true], "shown in that frame"); +} + /// A modal dialog is drawn in the main window, so the palettes' OS windows leave their topmost /// level while it is open and take it back after, or the dialog would be covered by them. #[test] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 2280853e1..be04485d0 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -873,9 +873,32 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { // long press) leaves it to that button. app.ui.flyout = None; } + if app.ui.flyout.is_some() { + mark_flyout_open(ctx); + } let _ = theme::semibold; } +/// The pass in which a tool group's flyout or the All Tools drawer last stayed open. +fn flyout_open_pass_id() -> egui::Id { + egui::Id::unique("flyout-open-pass") +} + +/// Records that a tool group's flyout or the All Tools drawer drew in this pass and stays open. +pub(crate) fn mark_flyout_open(ctx: &egui::Context) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(flyout_open_pass_id(), pass)); +} + +/// Whether a tool group's flyout or the All Tools drawer is on screen in the main window this pass. +/// One left set while its toolbar is hidden does not draw, so it does not count. The toolbars draw +/// before the panels, which read this. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn flyout_open(ctx: &egui::Context) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data(|d| d.get_temp::(flyout_open_pass_id())) == Some(pass) +} + /// The toolbar made with New Toolbar whose group the open flyout shows (none: the Tools panel's). pub(crate) fn flyout_toolbar_id() -> egui::Id { egui::Id::unique("flyout-toolbar") diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7bf38edc6..b91ffb976 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -678,6 +678,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`). - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. + - A floating panel's OS window hides while a tool group's flyout or the All Tools drawer is open in the main window, as it does under a modal dialog or a menu, and shows again when the flyout or the drawer closes (#5813; `a_tool_flyout_hides_the_palette_windows_until_it_closes`). - On the desktop a floating panel in an OS window stores the monitor it is on and reopens there at the next launch. When that monitor is no longer connected, the panel opens on the main window's monitor at the same offset (#5582). Outside macOS, a desktop whose monitors have different scale factors keeps opening floating panels on the main window's monitor. Test: `panel_docking::native_tests::a_floating_panel_reopens_on_the_monitor_where_it_was_left`. - A menu in a floating panel's OS window stops at that window's bottom edge and scrolls past it, and is never taller than the window (#5347; `a_long_panel_menu_scrolls_inside_a_short_native_window`). A panel's long submenus (Digits, Stylistic Sets, Save Selection, Move to Group), the libraries menu, the Color Guide's Limit to Library menu and the Layers panel's Filter popup scroll the same way. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. From e89f917f5eb3bf6da75707df3c1304207116ecc8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 21:30:25 -0800 Subject: [PATCH 42/52] Panels: long swatch, preset and Add Effect popups scroll inside a short window The gradient swatches menu, the Image Trace button's list of presets, the Appearance panel's swatch picker and the top level of the Add New Effect popup (Appearance panel, Properties) had no length limit, so rows past the bottom of a short window could not be reached. Each now uses menu_scroll. Items, order and commands do not change, and a list that fits does not scroll. --- crates/ui-egui/src/panels.rs | 2 + crates/ui-egui/src/panels/appearance.rs | 24 +-- crates/ui-egui/src/panels/appearance/tests.rs | 81 ++++++++++ crates/ui-egui/src/panels/gradient.rs | 40 ++--- crates/ui-egui/src/panels/gradient/tests.rs | 19 +++ crates/ui-egui/src/panels/image_trace.rs | 2 +- .../ui-egui/src/panels/image_trace/tests.rs | 25 +++ crates/ui-egui/src/panels/properties/tests.rs | 14 ++ .../ui-egui/src/panels/tests_long_popups.rs | 147 ++++++++++++++++++ docs/target-app-parity.md | 1 + 10 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 crates/ui-egui/src/panels/tests_long_popups.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index b91c563f2..41f1901ba 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -661,6 +661,8 @@ mod tests_freeform; #[cfg(test)] mod tests_links; #[cfg(test)] +mod tests_long_popups; +#[cfg(test)] mod tests_maskview; #[cfg(test)] mod tests_stroke; diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index ff05f1c7e..1cb2ede41 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1332,7 +1332,7 @@ fn swatch_button(app: &mut VectorcraftApp, ui: &mut Ui, r: Rect, paint: &Paint, open_popup(app, ui.ctx(), "color"); } egui::Popup::menu(&resp).open_memory((resp.clicked() && !mixer).then_some(egui::SetOpenCommand::Toggle)).show(|ui| { - swatch_picker(app, ui, target); + widgets::menu_scroll(ui, |ui| swatch_picker(app, ui, target)); }); } @@ -1608,18 +1608,20 @@ pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { None => groups.push((g, vec![(id.clone(), label.to_string())])), } } - for (g, items) in groups { - ui.menu_button(tl!(&g), |ui| { - crate::widgets::menu_scroll(ui, |ui| { - for (id, label) in items { - if ui.button(format!("{label}…")).clicked() { - app.run("effect.dialog", json!({"effect": id})).ok(); - ui.close(); + crate::widgets::menu_scroll(ui, |ui| { + for (g, items) in groups { + ui.menu_button(tl!(&g), |ui| { + crate::widgets::menu_scroll(ui, |ui| { + for (id, label) in items { + if ui.button(format!("{label}…")).clicked() { + app.run("effect.dialog", json!({"effect": id})).ok(); + ui.close(); + } } - } + }); }); - }); - } + } + }); } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs index 82042a9a3..12fd3155e 100644 --- a/crates/ui-egui/src/panels/appearance/tests.rs +++ b/crates/ui-egui/src/panels/appearance/tests.rs @@ -1,5 +1,86 @@ +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll, fx_menu_ends}; use super::*; +/// A selected rectangle, for the fill row's swatch picker and the Add Effect button. +fn app_with_a_selected_rectangle() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({ "ids": [id] })).unwrap(); + app +} + +/// The swatch of a fill row at the top of the window, with the size of its click target. +fn swatch_button_at_the_top() -> (impl FnMut(&mut VectorcraftApp, &mut Ui), egui::Vec2) { + let row = Rect::from_min_size(pos2(0.0, 8.0), vec2(400.0, 22.0)); + (move |app: &mut VectorcraftApp, ui: &mut Ui| swatch_button(app, ui, row, &Paint::None, Painted::Item(0)), vec2(22.0, 22.0)) +} + +/// One tile of the swatch picker. +const TILE: egui::Vec2 = vec2(15.5, 15.5); + +/// The picker has one tile per swatch: with enough of them it stops inside a short window and +/// scrolls, its rows stay 12 tiles wide, and a click on the last tile paints with the last swatch. +#[test] +fn a_long_swatch_picker_scrolls_inside_a_short_window() { + const HEIGHT: f32 = 200.0; + let mut app = app_with_a_selected_rectangle(); + for n in 0..150 { + app.run("swatch.new", json!({"name": format!("Long {n}"), "color": format!("#{:06x}", n + 1)})).unwrap(); + } + let doc = &app.session.active().unwrap().doc; + let last_swatch = doc.swatches.iter().chain(doc.swatch_groups.iter().flat_map(|group| &group.swatches)).last().unwrap().paint.clone(); + let (draw, chip) = swatch_button_at_the_top(); + let mut window = PopupWindow::new(&mut app, HEIGHT, draw); + window.click(&mut app, window.clickable(chip)[0].center()); + let tiles = window.clickable(TILE); + assert!(tiles.len() > 150, "one tile per swatch: {}", tiles.len()); + assert_eq!(tiles[11].top(), tiles[0].top(), "12 tiles in a row"); + assert!(tiles[12].top() > tiles[0].top(), "the 13th tile starts the next row"); + assert!(tiles[tiles.len() - 1].bottom() > HEIGHT, "the picker opens at the top of its list"); + window.wheel_down(&mut app, tiles[0].center()); + let tiles = window.clickable(TILE); + let last = tiles[tiles.len() - 1]; + assert!(last.bottom() <= HEIGHT, "the last tile at {last:?} is inside the window"); + window.click(&mut app, last.center()); + let node = first_subject(&app).expect("the rectangle"); + let (Paint::Solid { color, .. }, Paint::Solid { color: last_color, .. }) = (node.appearance.items[0].paint(), &last_swatch) else { + panic!("solid paints") + }; + assert_eq!(color, last_color); +} + +/// A picker with few swatches fits its tiles and the wheel does not move them. +#[test] +fn a_short_swatch_picker_does_not_scroll() { + let mut app = app_with_a_selected_rectangle(); + let (draw, chip) = swatch_button_at_the_top(); + let mut window = PopupWindow::new(&mut app, 900.0, draw); + window.click(&mut app, window.clickable(chip)[0].center()); + let tiles = window.clickable(TILE); + assert!(tiles[tiles.len() - 1].bottom() <= 900.0, "every tile is inside the window"); + window.wheel_down(&mut app, tiles[0].center()); + assert_eq!(window.clickable(TILE), tiles, "a list that fits does not scroll"); +} + +/// The Add New Effect button: the third of the bottom bar's 24 pt buttons. +fn fx_button(window: &PopupWindow) -> egui::Pos2 { + window.clickable(vec2(24.0, 24.0))[2].center() +} + +fn draw_bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui) { + let node = first_subject(app); + bottom(app, ui, node.as_ref(), Sel::None); +} + +/// The Add New Effect popup has one row per effect group, plug-in groups included. +#[test] +fn the_add_effect_popup_scrolls_inside_a_short_window() { + let (first, last) = fx_menu_ends(); + assert_last_row_scrolls_into_view(&mut app_with_a_selected_rectangle(), 150.0, draw_bottom_bar, fx_button, &first, &last); + assert_short_list_does_not_scroll(&mut app_with_a_selected_rectangle(), draw_bottom_bar, fx_button, &first, &last); +} + /// Duplicating rows highlights the copies, each right above its original, as the Layers panel /// does: the active row's copy becomes active, the anchor's copy the anchor. #[test] diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 631d38ab7..07744bf1b 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -547,26 +547,28 @@ fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), vec2(12.0, 12.0)), if dresp.hovered() { t.text_strong } else { t.icon }); let dresp = dresp.on_hover_text(tl!("Gradient swatches")); egui::Popup::menu(&dresp).show(|ui| { - ui.set_min_width(200.0); - let mut chosen = None; - let swatches = gradient_swatches(app); - if swatches.is_empty() { - widgets::dim_label(ui, tl!("No gradient swatches")); - } - for (name, grad) in &swatches { - // The gradient swatch the gradient was applied from is lit. - if gradient_row(ui, &t, grad, name, g.swatch.as_ref() == Some(name)).clicked() { - chosen = Some(name.clone()); + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(200.0); + let mut chosen = None; + let swatches = gradient_swatches(app); + if swatches.is_empty() { + widgets::dim_label(ui, tl!("No gradient swatches")); } - } - ui.separator(); - if menu_item(ui, tl!("Save to Swatches"), is_grad, false) { - save_to_swatches(app, g); - } - if let Some(name) = chosen { - super::apply_click(app, ui, json!({ "swatch": name })); - ui.close(); - } + for (name, grad) in &swatches { + // The gradient swatch the gradient was applied from is lit. + if gradient_row(ui, &t, grad, name, g.swatch.as_ref() == Some(name)).clicked() { + chosen = Some(name.clone()); + } + } + ui.separator(); + if menu_item(ui, tl!("Save to Swatches"), is_grad, false) { + save_to_swatches(app, g); + } + if let Some(name) = chosen { + super::apply_click(app, ui, json!({ "swatch": name })); + ui.close(); + } + }); }); } diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 65602079b..e27da3cfd 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -1,8 +1,27 @@ use egui::{Event, Modifiers, PointerButton}; use vectorcraft_engine::Session; +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll}; use super::*; +/// The gradient swatches menu has one row per gradient swatch: with enough of them it stops inside a +/// short window and the wheel reaches Save to Swatches. With few of them it does not scroll. +#[test] +fn a_long_gradient_swatch_menu_scrolls_inside_a_short_window() { + let stops = json!([{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]); + let mut app = app(json!({"gradient": {"stops": stops}})); + app.run("swatch.new", json!({"name": "Dusk", "gradient": {"stops": stops}})).unwrap(); + let paint = current(&app).expect("a gradient fill"); + let short = paint.clone(); + let arrow = |window: &PopupWindow| window.clickable(vec2(14.0, 40.0))[0].center(); + let first = gradient_swatches(&app)[0].0.clone(); + assert_short_list_does_not_scroll(&mut app, move |app, ui| thumbnail(app, ui, &short, true), arrow, &first, "Save to Swatches"); + for n in 0..30 { + app.run("swatch.new", json!({"name": format!("Long {n}"), "gradient": {"stops": stops}})).unwrap(); + } + assert_last_row_scrolls_into_view(&mut app, 200.0, move |app, ui| thumbnail(app, ui, &paint, true), arrow, &first, "Save to Swatches"); +} + #[test] fn offsets_and_json() { assert_eq!(x_to_offset(50.0, 0.0, 200.0), 0.25); diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 3c878633f..73a1b8986 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -314,7 +314,7 @@ pub(crate) fn trace_button(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { if main.on_hover_text(tl!("Trace the image with the Default preset")).clicked() { trace_with(app, "Default"); } - if let Some(p) = egui::Popup::menu(&arrow).show(|ui| preset_list(app, ui, "")).and_then(|r| r.inner) { + if let Some(p) = egui::Popup::menu(&arrow).show(|ui| widgets::menu_scroll(ui, |ui| preset_list(app, ui, ""))).and_then(|r| r.inner) { trace_with(app, &p); } } diff --git a/crates/ui-egui/src/panels/image_trace/tests.rs b/crates/ui-egui/src/panels/image_trace/tests.rs index d946d4bdc..56de0cdb0 100644 --- a/crates/ui-egui/src/panels/image_trace/tests.rs +++ b/crates/ui-egui/src/panels/image_trace/tests.rs @@ -1,3 +1,4 @@ +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, assert_short_list_does_not_scroll}; use super::*; use crate::panels::library_panel; use egui::{Event, PointerButton, Pos2, Rect, vec2}; @@ -41,6 +42,30 @@ fn traced_app_with_ink(ink: u8) -> VectorcraftApp { app } +/// The Tracing Presets arrow at the right end of the Image Trace button. +fn tracing_presets_arrow(window: &PopupWindow) -> Pos2 { + let label = window.text("Image Trace").expect("the Image Trace button"); + Pos2::new(TRACE_BUTTON_W - 4.0, label.center().y) +} + +/// The Image Trace button's list of presets grows with saved presets: with enough of them it stops +/// inside a short window and the wheel reaches the last one. With only the built-in presets it does +/// not scroll. +#[test] +fn a_long_tracing_preset_list_scrolls_inside_a_short_window() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let draw = |app: &mut VectorcraftApp, ui: &mut Ui| trace_button(app, ui, TRACE_BUTTON_W); + let built_in = app.session.execute("imageTrace.presets", &json!({})).unwrap(); + let name = |p: &Value| p["name"].as_str().unwrap().to_string(); + let built_in = built_in["presets"].as_array().unwrap(); + let (first, last) = (name(&built_in[0]), name(&built_in[built_in.len() - 1])); + assert_short_list_does_not_scroll(&mut app, draw, tracing_presets_arrow, &first, &last); + for n in 0..30 { + app.session.execute("imageTrace.presets.save", &json!({"name": format!("Saved {n}"), "preset": "Default"})).unwrap(); + } + assert_last_row_scrolls_into_view(&mut app, 200.0, draw, tracing_presets_arrow, &first, "Saved 29"); +} + #[test] fn a_large_image_traces_and_the_status_bar_says_it_is_slower() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 5e82d51f1..ea1a60095 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -2,6 +2,7 @@ use egui::{Event, PointerButton, Pos2, Rect, vec2}; use serde_json::Value; use vectorcraft_engine::Session; +use super::super::tests_long_popups::{PopupWindow, assert_last_row_scrolls_into_view, fx_menu_ends}; use super::*; /// One frame of the panel with `events` → the texts painted, with their rects. @@ -30,6 +31,19 @@ fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)] frame(app, ctx, vec![b(false)]); } +/// The Appearance section's fx button opens the Add Effect popup, with one row per effect group, +/// plug-in groups included: in a short window the wheel reaches its last row. +#[test] +fn the_fx_popup_scrolls_inside_a_short_window() { + let (first, last) = fx_menu_ends(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let fx = |window: &PopupWindow| window.text("fx").expect("the fx button").center(); + assert_last_row_scrolls_into_view(&mut app, 190.0, appearance_section, fx, &first, &last); +} + /// One selected clip group has Edit Clipping Path and Edit Contents at the top of the panel, /// under its Clip Group heading and before every section, and they select its clipping path or /// its clipped art; one that clips nothing has Edit Clipping Path alone. A plain group, or a clip diff --git a/crates/ui-egui/src/panels/tests_long_popups.rs b/crates/ui-egui/src/panels/tests_long_popups.rs new file mode 100644 index 000000000..8597ea995 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_long_popups.rs @@ -0,0 +1,147 @@ +//! A headless window for checking that a panel's popup with no fixed length stays inside the window. + +use egui::{Event, PointerButton, Pos2, Rect, Ui, vec2}; + +use super::VectorcraftApp; + +/// The frames that opening a popup takes. +const OPEN_FRAMES: usize = 8; +/// The frames that finishing a wheel's scrolling takes. +const SCROLL_FRAMES: usize = 40; +/// The window's width, wider than every popup. +const WIDTH: f32 = 500.0; +/// A window tall enough for every short list. +const TALL: f32 = 900.0; + +/// What a window draws each frame. +type Draw<'a> = Box; + +/// A window `height` pt tall with its own clock, drawing `draw` each frame. +pub(super) struct PopupWindow<'a> { + ctx: egui::Context, + time: f64, + height: f32, + draw: Draw<'a>, + texts: Vec<(String, Rect)>, +} + +impl<'a> PopupWindow<'a> { + pub(super) fn new(app: &mut VectorcraftApp, height: f32, draw: impl FnMut(&mut VectorcraftApp, &mut Ui) + 'a) -> Self { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut window = Self { ctx, time: 0.0, height, draw: Box::new(draw), texts: vec![] }; + window.frames(app, 2); + window + } + + /// One frame with `events`. + fn frame(&mut self, app: &mut VectorcraftApp, events: Vec) { + self.time += 1.0 / 60.0; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(WIDTH, self.height))); + let input = egui::RawInput { time: Some(self.time), screen_rect, events, ..Default::default() }; + let draw = &mut self.draw; + let mut out = self.ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); + self.texts = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + } + + fn frames(&mut self, app: &mut VectorcraftApp, count: usize) { + for _ in 0..count { + self.frame(app, vec![]); + } + } + + /// Where the last frame drew the text `wanted`: the last one drawn, so a popup's row comes + /// before a panel's text of the same name. + pub(super) fn text(&self, wanted: &str) -> Option { + self.texts.iter().rfind(|(text, _)| text.trim() == wanted).map(|(_, rect)| *rect) + } + + /// The rects of the last frame's clickable widgets `size` across, in the order they were drawn. + pub(super) fn clickable(&self, size: egui::Vec2) -> Vec { + self.ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.size() == size) + .map(|w| w.rect) + .collect() + }) + } + + /// A click at `at`, and the frames a popup takes to open. + pub(super) fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) { + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + self.frame(app, vec![Event::PointerMoved(at), button(true)]); + self.frame(app, vec![button(false)]); + self.frames(app, OPEN_FRAMES); + } + + /// Turns the wheel far down with the pointer at `over`, and lets the scrolling finish. + pub(super) fn wheel_down(&mut self, app: &mut VectorcraftApp, over: Pos2) { + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -5000.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + self.frame(app, vec![Event::PointerMoved(over), wheel]); + self.frames(app, SCROLL_FRAMES); + } +} + +/// The Add Effect popup's first and last rows with no plug-ins: the effect catalog's groups, in order. +pub(super) fn fx_menu_ends() -> (String, String) { + let mut groups: Vec<&str> = vec![]; + let catalog = vectorcraft_effects::effect_catalog(); + for group in catalog.iter().map(|effect| effect.menu.get(1).copied().unwrap_or("Other")) { + if !groups.contains(&group) { + groups.push(group); + } + } + (groups[0].to_string(), groups[groups.len() - 1].to_string()) +} + +/// In a window `height` pt tall, the popup of the button `pick` finds opens at its row `first`, and +/// the wheel brings its row `last` into the window. +pub(super) fn assert_last_row_scrolls_into_view( + app: &mut VectorcraftApp, + height: f32, + draw: impl FnMut(&mut VectorcraftApp, &mut Ui), + pick: impl FnOnce(&PopupWindow) -> Pos2, + first: &str, + last: &str, +) { + let mut window = PopupWindow::new(app, height, draw); + let button = pick(&window); + window.click(app, button); + let over = window.text(first).unwrap_or_else(|| panic!("the popup's first row, {first}")).center(); + assert!(window.text(last).is_none_or(|row| row.bottom() > height), "the popup opens at the top of its list, with {last} out of view"); + window.wheel_down(app, over); + let row = window.text(last).unwrap_or_else(|| panic!("the wheel brings {last} into the {height} pt window")); + assert!(row.bottom() <= height, "{last} at {row:?} is inside the {height} pt window"); +} + +/// In a tall window the popup of the button `pick` finds shows its rows `first` to `last` without +/// scrolling, and the wheel moves neither. +pub(super) fn assert_short_list_does_not_scroll( + app: &mut VectorcraftApp, + draw: impl FnMut(&mut VectorcraftApp, &mut Ui), + pick: impl FnOnce(&PopupWindow) -> Pos2, + first: &str, + last: &str, +) { + let mut window = PopupWindow::new(app, TALL, draw); + let button = pick(&window); + window.click(app, button); + let shown = |window: &PopupWindow| { + let (top, end) = (window.text(first).expect("the first row"), window.text(last).expect("the last row")); + assert!(end.bottom() <= TALL, "{last} at {end:?} is inside the window"); + (top, end) + }; + let before = shown(&window); + window.wheel_down(app, before.0.center()); + assert_eq!(shown(&window), before, "a list that fits does not scroll"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b91ffb976..1ea7d3bc2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1152,6 +1152,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) page describes up to five Pattern brush tiles: sides, inner corner, outer corner, beginning and end. The archived [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page says to add pattern tiles to the Swatches panel and select a swatch from the scroll list for each tile. Pattern Brush Options offers document pattern swatches in all five tile menus, including swatches named like built-in choices. The brush copies each swatch's whole art and tile frame. Side requires art; explicit None on a corner prevents fallback to the other corner's art. After a swatch is deleted, the brush keeps drawing its copied art and shows it as Original. The cited pages do not describe copying the whole art, requiring Side, suppressing corner fallback after explicit None, or showing deleted swatch art as Original. `brush.options` accepts swatch names and an object form for names that match built-in choices, and rejects unknown patterns before editing. Evidence: `tests::brushsym::pattern_tile_choices_copy_whole_swatch_art_and_reject_invalid_side` and `tests::brushsym::pattern_tile_object_selects_reserved_swatch_names` (vectorcraft-engine), `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), and `dialogs::brush_options::tests::pattern_tile_menus_use_document_patterns_and_side_none_keeps_dialog_open`, `dialogs::brush_options::tests::pattern_options_borrowed_swatch_corner_keeps_its_frame`, and `dialogs::brush_options::tests::pattern_options_selects_swatches_with_reserved_names` (vectorcraft-ui-egui). - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing. + The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`). An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame. A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. From dbcdbf2466e8a2296ce769cdf3ff1741a7a3495f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 20:55:11 -0800 Subject: [PATCH 43/52] Open: the first frame uses the layouts the open made for type objects Opening a document laid out every type object for its bounds and baselines and threw the layouts away, and the first frame laid the same objects out again. The open now keeps them, up to 128 MiB, and a frame takes each one once. A frame takes a layout only for a text object equal to the one laid out, and only while the fonts, the system fallback setting and the hyphenation exceptions are unchanged. When the last frame being drawn ends, the rest are dropped if a frame took one, and all of them are dropped after eight frames that take none. A layout with bitmap or color glyphs and a text object with a gradient are not kept. --- crates/engine/src/cmd/typecmd.rs | 12 + crates/engine/src/session/documents.rs | 2 +- crates/engine/tests/open_kept_layouts.rs | 279 ++++++++++++++++++++ crates/render/src/renderer.rs | 2 + crates/render/src/text_geom.rs | 2 +- crates/text/src/fontdb/db.rs | 12 + crates/text/src/hyphen.rs | 9 + crates/text/src/kept.rs | 318 +++++++++++++++++++++++ crates/text/src/layout/driver.rs | 2 + crates/text/src/lib.rs | 2 + crates/text/src/test_fonts.rs | 19 ++ crates/text/src/tests.rs | 1 + crates/text/src/tests/kept_layouts.rs | 316 ++++++++++++++++++++++ docs/target-app-parity.md | 1 + 14 files changed, 975 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/open_kept_layouts.rs create mode 100644 crates/text/src/kept.rs create mode 100644 crates/text/src/tests/kept_layouts.rs diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 26db35c3d..4ad577651 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -298,6 +298,18 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { t.cached_baselines = lay.baselines(); } +/// [`refresh_bounds`] for a document that is opening: the first frame takes the layout +/// ([`vectorcraft_text::take_kept_layout`]) instead of laying the object out again. +pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { + t.normalize_paras(); + auto_height(t); + let stamp = vectorcraft_text::LayoutStamp::now(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + t.cached_bounds = Some(lay.bounds); + t.cached_baselines = lay.baselines(); + vectorcraft_text::keep_layout(t, stamp, lay); +} + /// The text-space bounds of area type's frame, when Auto Size can fit it: a rectangle (one /// subpath, as large as its bounds) of horizontal type in one row, with some height. fn auto_height_frame(t: &TextObject) -> Option { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 52dcba146..f2104d75f 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -335,7 +335,7 @@ fn refresh_tree_text_bounds(n: &mut Arc) { if let NodeKind::Text(t) = &mut n.kind && t.cached_bounds.is_none() { - cmd::typecmd::refresh_bounds(t); + cmd::typecmd::refresh_bounds_for_first_frame(t); } if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { refresh_tree_text_bounds(&mut m.art); diff --git a/crates/engine/tests/open_kept_layouts.rs b/crates/engine/tests/open_kept_layouts.rs new file mode 100644 index 000000000..a65e0dfc3 --- /dev/null +++ b/crates/engine/tests/open_kept_layouts.rs @@ -0,0 +1,279 @@ +//! A document of type opened and drawn with the layouts the open keeps, compared with the same +//! document drawn after laying every object out again: the nodes, bounds, baselines and pixels are +//! the same, and the first frame lays out only what it could not take. Its own test binary: kept +//! layouts and the font database's generation belong to the whole process. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::{Mutex, MutexGuard}; + +use serde_json::{Value, json}; +use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject, TextRun}; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::fileio; +use vectorcraft_geom::{Affine, Point}; +use vectorcraft_render::{RenderOptions, Renderer}; +use vectorcraft_text::test_fonts::{drop_kept_layouts, layouts_on_this_thread}; +use vectorcraft_text::{FontDb, KeptLayoutsFrame}; + +const WIDTH: u32 = 900; +const HEIGHT: u32 = 1300; + +/// Taken by each test: kept layouts belong to the whole process. +static PROCESS: Mutex<()> = Mutex::new(()); + +fn process() -> MutexGuard<'static, ()> { + PROCESS.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn style() -> CharStyle { + CharStyle { size: 40.0, ..CharStyle::default() } +} + +fn file_of(objects: Vec) -> Vec { + let mut doc = Document::new(f64::from(WIDTH), f64::from(HEIGHT)); + let layer = doc.layers[0].id; + for (i, mut t) in objects.into_iter().enumerate() { + t.xf = Affine::translate((20.0, 70.0 * (i + 1) as f64)); + let id = doc.alloc_id(); + doc.insert(Some(layer), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + } + vectorcraft_format::save_file(&doc) +} + +fn latin() -> Vec { + file_of(["Plain Latin type", "AVATAR office", "123 ... 456"].map(|s| TextObject::point(Point::ZERO, s, style())).into()) +} + +fn mixed_fonts() -> Vec { + let font = |family: &str, text: &str| TextRun::new(text, CharStyle { font_family: family.into(), ..style() }); + let mut mixed = TextObject::point(Point::ZERO, "", style()); + mixed.runs = vec![font("Inter", "Inter "), font("JetBrains Mono", "Mono "), font("Source Serif 4", "Serif "), font("No Such Family", "Missing")]; + file_of(vec![mixed, TextObject::point(Point::ZERO, "Source Sans", style())]) +} + +fn complex_scripts() -> Vec { + let scripts = [ + "\u{645}\u{631}\u{62d}\u{628}\u{627}", + "\u{5e9}\u{5dc}\u{5d5}\u{5dd}", + "\u{928}\u{92e}\u{938}\u{94d}\u{924}\u{947}", + "\u{e2a}\u{e27}\u{e31}\u{e2a}\u{e14}\u{e35}", + "\u{65e5}\u{672c}\u{8a9e} abc", + ]; + file_of(scripts.map(|s| TextObject::point(Point::ZERO, s, style())).into()) +} + +/// Area type, and a story through two frames, as a session saves them. +fn threaded_story() -> Vec { + let mut s = Session::new(); + s.add_document(Document::new(f64::from(WIDTH), f64::from(HEIGHT)), None); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute( + "text.create", + &json!({"x": 10, "y": 300, "text": "Area type on its own. ".repeat(20), "size": 12, "area": {"width": 300, "height": 200}}), + ) + .unwrap(); + vectorcraft_format::save_file(&s.doc().unwrap().doc) +} + +fn documents() -> Vec<(&'static str, Vec)> { + vec![("Latin", latin()), ("mixed fonts", mixed_fonts()), ("complex scripts", complex_scripts()), ("threaded story", threaded_story())] +} + +/// The session `bytes` opened in, as `document.open` reads a file and then opens it. +fn open(bytes: &[u8]) -> Session { + let name = "layouts.vectorcraft"; + let mut s = Session::new(); + let read = fileio::read_for_open(name, bytes, Some(name.into()), &Value::Null, false, false).unwrap(); + fileio::open_read(&mut s, read).unwrap(); + s +} + +fn frame(doc: &Document) -> Vec { + Renderer::new().render(doc, WIDTH, HEIGHT, Affine::IDENTITY, &RenderOptions::default()).pixels +} + +fn type_objects(doc: &Document) -> Vec { + let mut all = vec![]; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + all.push((**t).clone()); + } + }); + all +} + +/// Open once to load every font the document uses: a load changes the font database's generation. +fn fonts_loaded(bytes: &[u8]) { + let s = open(bytes); + frame(&s.doc().unwrap().doc); + drop_kept_layouts(); +} + +#[test] +fn the_first_frame_draws_the_pixels_of_laying_every_object_out_again() { + let _process = process(); + for (name, bytes) in documents() { + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let taken = frame(doc); + drop_kept_layouts(); + let again = frame(doc); + assert!(again != frame(&Document::new(f64::from(WIDTH), f64::from(HEIGHT))), "{name}: the frame shows the type"); + let differing = taken.iter().zip(&again).filter(|(a, b)| a != b).count(); + assert_eq!((taken.len(), differing), (again.len(), 0), "{name}: bytes of the frame that differ"); + } +} + +#[test] +fn the_kept_layouts_are_the_layouts_of_the_opened_objects() { + let _process = process(); + for (name, bytes) in documents() { + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let mut taken = 0; + let frame_drawn = KeptLayoutsFrame::begin(); + for t in type_objects(doc) { + let fresh = vectorcraft_text::layout(FontDb::global(), &t); + assert_eq!(t.cached_bounds, Some(fresh.bounds), "{name}: {:?}", t.plain_text()); + assert_eq!(t.cached_baselines, fresh.baselines(), "{name}: {:?}", t.plain_text()); + if let Some(kept) = vectorcraft_text::take_kept_layout(&t) { + let outlines = + |l: &vectorcraft_text::TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.origin, g.outline.clone())).collect::>(); + assert!(outlines(&kept) == outlines(&fresh), "{name}: {:?}", t.plain_text()); + assert_eq!(kept.bounds, fresh.bounds, "{name}: {:?}", t.plain_text()); + taken += 1; + } + } + assert!(taken > 0, "{name}: the open kept layouts"); + drop(frame_drawn); + drop_kept_layouts(); + let again = open(&bytes); + assert!(*again.doc().unwrap().doc == **doc, "{name}: another open reads the same document"); + drop_kept_layouts(); + } +} + +#[test] +fn the_first_frame_lays_out_no_object_the_open_laid_out() { + let _process = process(); + let bytes = latin(); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 0, "the frame takes every layout"); + // A second renderer has nothing left to take. + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 3); +} + +#[test] +fn an_edited_object_is_laid_out_again() { + let _process = process(); + let bytes = latin(); + fonts_loaded(&bytes); + let mut s = open(&bytes); + let id = { + let mut ids = vec![]; + s.doc().unwrap().doc.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + ids.push(n.id.0); + } + }); + ids[0] + }; + s.execute("text.setText", &json!({"id": id, "text": "Edited"})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let before = layouts_on_this_thread(); + let edited = frame(&doc); + assert_eq!(layouts_on_this_thread() - before, 1, "only the edited object is laid out"); + drop_kept_layouts(); + assert!(edited == frame(&doc), "the frame is what laying every object out again draws"); +} + +#[test] +fn changing_the_hyphenation_exceptions_or_the_fonts_lays_every_object_out_again() { + let _process = process(); + let bytes = latin(); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + vectorcraft_text::set_hyphenation_exceptions(""); + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 3, "hyphenation exceptions were set"); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + FontDb::global().add_font(vectorcraft_text::test_fonts::variable_font().unwrap()); + let generation = FontDb::global().generation(); + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 3, "fonts were added (generation {generation})"); +} + +/// The layout of an object the first frame culls is dropped when that frame ends. +#[test] +fn a_frame_lets_go_of_what_it_did_not_draw() { + let _process = process(); + let mut objects = vec![TextObject::point(Point::ZERO, "In view", style())]; + objects.extend((0..3).map(|i| TextObject::point(Point::ZERO, &format!("Out of view {i}"), style()))); + let bytes = file_of(objects); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let small = Renderer::new().render(doc, 300, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(small.width, 300); + let out = type_objects(doc).into_iter().find(|t| t.plain_text() == "Out of view 2").unwrap(); + let _frame = KeptLayoutsFrame::begin(); + assert!(vectorcraft_text::take_kept_layout(&out).is_none()); +} + +/// A thumbnail lays its object out, and the first frame still takes that object's layout. +#[test] +fn a_thumbnail_leaves_the_layouts_for_the_first_frame() { + let _process = process(); + let bytes = latin(); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let mut ids = vec![]; + doc.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + ids.push(n.id); + } + }); + let before = layouts_on_this_thread(); + assert!(Renderer::new().render_thumbnail(doc, ids[0], 64).is_some()); + assert_eq!(layouts_on_this_thread() - before, 1, "the thumbnail lays its object out"); + let before = layouts_on_this_thread(); + frame(doc); + assert_eq!(layouts_on_this_thread() - before, 0, "the frame takes every layout"); +} + +/// A frame of ink amounts ends like any other. +#[test] +fn an_ink_frame_lets_go_of_what_it_did_not_draw() { + let _process = process(); + let mut objects = vec![TextObject::point(Point::ZERO, "In view", style())]; + objects.extend((0..3).map(|i| TextObject::point(Point::ZERO, &format!("Out of view {i}"), style()))); + let bytes = file_of(objects); + fonts_loaded(&bytes); + let s = open(&bytes); + let doc = &s.doc().unwrap().doc; + let region = vectorcraft_geom::Rect::new(0.0, 0.0, 300.0, 100.0); + assert!(!Renderer::new().render_region_inks(doc, region, 1.0, &RenderOptions::default()).is_empty()); + let out = type_objects(doc).into_iter().find(|t| t.plain_text() == "Out of view 2").unwrap(); + let _frame = KeptLayoutsFrame::begin(); + assert!(vectorcraft_text::take_kept_layout(&out).is_none()); +} diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index e22d4df5e..2ad5170d4 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -91,6 +91,7 @@ impl Renderer { /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered { + let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); self.render_as(doc, width, height, view, opts, false) } @@ -315,6 +316,7 @@ impl Renderer { pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec { let (w, h) = region_pixels(region, scale); let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + let _frame = vectorcraft_text::KeptLayoutsFrame::begin(); self.render_as(doc, w, h, view, opts, true).pixels } diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 0d65dfbff..5e3c9496a 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -147,7 +147,7 @@ pub(crate) fn text_geom(t: &TextObject) -> TextGeom { /// Glyph geometry of `t`; `snap` (text space → device pixels) puts the glyphs on whole pixels. pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { let db = vectorcraft_text::FontDb::global(); - let mut layout = vectorcraft_text::layout(db, t); + let mut layout = vectorcraft_text::take_kept_layout(t).unwrap_or_else(|| vectorcraft_text::layout(db, t)); if let Some(xf) = snap { layout.snap_to_pixels(xf); } diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 226ad7832..9cc32eeee 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -83,6 +83,18 @@ impl FontDb { let _ = on; } + /// Is the lazy system-font fallback on ([`Self::set_system_fallback`])? + pub(crate) fn system_fallback_on(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + /// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the /// first time a lookup by family name needs them (or ahead of time by /// [`FontDb::scan_in_background`]). diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index adb3a62bf..ffb9572e3 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -90,6 +90,15 @@ fn parse_list(source: &str) -> Exceptions { /// case-insensitive. Empty clears the list. pub fn set_hyphenation_exceptions(source: &str) { *store() = parse_list(source); + EXCEPTIONS_SET.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} + +/// How many times [`set_hyphenation_exceptions`] has run. +static EXCEPTIONS_SET: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// A number that changes whenever [`set_hyphenation_exceptions`] runs. +pub(crate) fn exceptions_generation() -> u64 { + EXCEPTIONS_SET.load(std::sync::atomic::Ordering::Relaxed) } /// The last string passed to [`set_hyphenation_exceptions`] (empty when none). diff --git a/crates/text/src/kept.rs b/crates/text/src/kept.rs new file mode 100644 index 000000000..801064252 --- /dev/null +++ b/crates/text/src/kept.rs @@ -0,0 +1,318 @@ +//! The layouts of a document that is opening, kept for its first frame: opening lays out every type +//! object for its bounds and baselines, and the first frame needs the same glyphs. A kept layout is +//! found by comparing the whole text object, and only while the fonts and the hyphenation exceptions +//! are the ones it was made with. Each is taken once, the frame that takes one drops the rest, a run of +//! [`KEPT_LAYOUTS_FRAMES`] frames that take none drops them all, and together they hold at most +//! [`KEPT_LAYOUTS_BUDGET`] bytes. + +use std::collections::{HashMap, VecDeque}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::mem::{size_of, size_of_val}; +use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError}; + +use vectorcraft_doc::color::Paint; +use vectorcraft_doc::{CharStyle, ParaStyle, TextKind, TextObject, TextRun}; +use vectorcraft_geom::{Anchor, PathData}; + +use crate::{FontDb, PositionedGlyph, TextLayout}; + +/// The most bytes of layouts kept at once; the oldest are dropped first. +const KEPT_LAYOUTS_BUDGET: usize = 128 << 20; + +/// How many frames may end with kept layouts on the shelf: the frames of other documents, or of a view +/// that shows no type, take none. +const KEPT_LAYOUTS_FRAMES: u32 = 8; + +/// The most layouts kept under one hash ([`text_hash`]): taking one compares it with those. +const KEPT_LAYOUTS_PER_HASH: usize = 32; + +/// What a layout reads besides its text object, in the global font database. Take it before laying +/// out: a layout that loads a font ends with another stamp, and is not kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LayoutStamp { + fonts: u64, + system_fallback: bool, + hyphenation: u64, +} + +impl LayoutStamp { + pub fn now() -> Self { + let db = FontDb::global(); + Self { fonts: db.generation(), system_fallback: db.system_fallback_on(), hyphenation: crate::hyphen::exceptions_generation() } + } +} + +struct Kept { + /// Tells this one from the others under its hash, in [`Shelf::order`]. + number: u64, + text: TextObject, + layout: TextLayout, + bytes: usize, +} + +#[derive(Default)] +struct Shelf { + stamp: Option, + kept: HashMap>, + /// Hash and number of each kept layout, the oldest first. Layouts already taken stay listed + /// until they reach the front. + order: VecDeque<(u64, u64)>, + next: u64, + bytes: usize, + taken: bool, + /// Frames that have ended since the last keep. + frames: u32, + /// Frames being drawn now, on any thread. + drawing: u32, +} + +impl Shelf { + fn clear(&mut self) { + self.kept.clear(); + self.order.clear(); + self.bytes = 0; + self.taken = false; + self.frames = 0; + } + + fn remove(&mut self, hash: u64, found: impl Fn(&Kept) -> bool) -> Option { + let list = self.kept.get_mut(&hash)?; + let kept = list.swap_remove(list.iter().position(found)?); + if list.is_empty() { + self.kept.remove(&hash); + } + self.bytes -= kept.bytes; + Some(kept) + } + + /// Keep `layout` of `text`, laid out at `stamp`, when that is still `now`. Layouts of an older + /// stamp are dropped. A layout whose memory is not counted ([`counts_its_memory`]) and one past + /// [`KEPT_LAYOUTS_PER_HASH`] are not kept. + fn keep(&mut self, text: &TextObject, stamp: LayoutStamp, now: LayoutStamp, layout: TextLayout, budget: usize) { + if stamp != now { + return; + } + if self.stamp != Some(now) { + self.clear(); + self.stamp = Some(now); + } + let bytes = layout_bytes(&layout) + text_bytes(text); + if bytes > budget || !counts_its_memory(text, &layout) { + return; + } + let (hash, number) = (text_hash(text), self.next); + if self.kept.get(&hash).is_some_and(|list| list.len() >= KEPT_LAYOUTS_PER_HASH) { + return; + } + while self.bytes + bytes > budget { + let Some((hash, number)) = self.order.pop_front() else { break }; + self.remove(hash, |k| k.number == number); + } + self.frames = 0; + self.next += 1; + self.bytes += bytes; + self.order.push_back((hash, number)); + self.kept.entry(hash).or_default().push(Kept { number, text: text.clone(), layout, bytes }); + } + + /// The layout kept for a text object equal to `text`, no longer kept after this. + fn take(&mut self, text: &TextObject, now: LayoutStamp) -> Option { + if self.kept.is_empty() { + return None; + } + if self.stamp != Some(now) { + self.clear(); + return None; + } + let kept = self.remove(text_hash(text), |k| k.text == *text)?; + if self.kept.is_empty() { + self.order.clear(); + } + self.taken = true; + Some(kept.layout) + } + + fn frame_begin(&mut self) { + self.drawing += 1; + } + + /// A frame ended: when no other frame is being drawn and a frame took a layout, or + /// [`KEPT_LAYOUTS_FRAMES`] frames have ended since the last keep, the others are dropped. + fn frame_done(&mut self) { + self.drawing = self.drawing.saturating_sub(1); + self.frames = self.frames.saturating_add(1); + if self.drawing == 0 && (self.taken || self.frames >= KEPT_LAYOUTS_FRAMES) { + self.clear(); + } + } +} + +fn text_hash(t: &TextObject) -> u64 { + let mut h = DefaultHasher::new(); + t.runs.iter().for_each(|r| { + r.text.hash(&mut h); + r.style.font_family.hash(&mut h); + r.style.font_style.hash(&mut h); + r.style.size.to_bits().hash(&mut h); + }); + t.xf.as_coeffs().iter().for_each(|c| c.to_bits().hash(&mut h)); + h.finish() +} + +/// Does [`text_bytes`] and [`layout_bytes`] count everything `t` and `layout` hold? They do not count +/// a gradient's stops, or a glyph's bitmap or color art. +fn counts_its_memory(t: &TextObject, layout: &TextLayout) -> bool { + let gradient = |p: &Paint| matches!(p, Paint::Gradient(_)); + !t.runs.iter().any(|r| gradient(&r.style.fill) || gradient(&r.style.stroke)) + && !layout.glyphs.iter().chain(&layout.ruby).any(|g| g.image.is_some() || g.colour.is_some()) +} + +fn paint_bytes(p: &Paint) -> usize { + match p { + Paint::Solid { swatch: Some(name), .. } | Paint::Pattern { pattern: name, .. } => name.len(), + _ => 0, + } +} + +fn path_bytes(p: &PathData) -> usize { + size_of_val(p.subpaths.as_slice()) + p.anchor_count() * size_of::() +} + +fn style_bytes(s: &CharStyle) -> usize { + s.font_family.len() + + s.font_style.len() + + s.font_version.as_ref().map_or(0, String::len) + + s.style_name.as_ref().map_or(0, String::len) + + s.language.as_ref().map_or(0, String::len) + + size_of_val(s.features.as_slice()) + + s.features.iter().map(String::len).sum::() + + s.font_axes.keys().map(|k| size_of::<(String, f64)>() + k.len()).sum::() + + paint_bytes(&s.fill) + + paint_bytes(&s.stroke) + + s.stroke_dash.as_ref().map_or(0, |d| size_of_val(d.pattern.as_slice())) + + s.glyph_alternate.as_ref().map_or(0, |g| g.font.len() + g.version.len()) +} + +fn run_bytes(r: &TextRun) -> usize { + size_of::() + r.text.len() + style_bytes(&r.style) + r.inline.as_ref().map_or(0, |i| i.symbol.len()) +} + +fn para_bytes(p: &ParaStyle) -> usize { + size_of_val(p.tabs.as_slice()) + p.tabs.iter().map(|t| t.leader.len()).sum::() + p.style_name.as_ref().map_or(0, String::len) +} + +fn text_bytes(t: &TextObject) -> usize { + let kind = match &t.kind { + TextKind::Point => 0, + TextKind::Area { frame } => path_bytes(frame), + TextKind::OnPath { path, .. } => path_bytes(path), + }; + size_of::() + + kind + + t.runs.iter().map(run_bytes).sum::() + + t.ruby.iter().map(|r| size_of_val(r) + r.text.len()).sum::() + + para_bytes(&t.para) + + t.paras.iter().map(|p| size_of_val(p) + para_bytes(p)).sum::() + + size_of_val(t.cached_baselines.as_slice()) + + t.wrap.iter().map(|w| size_of_val(w) + path_bytes(&w.path)).sum::() +} + +fn glyph_bytes(glyphs: &[PositionedGlyph]) -> usize { + size_of_val(glyphs) + glyphs.iter().map(|g| size_of_val(g.outline.elements()) + size_of_val(g.carets.as_slice())).sum::() +} + +fn layout_bytes(l: &TextLayout) -> usize { + size_of::() + + glyph_bytes(&l.glyphs) + + glyph_bytes(&l.ruby) + + size_of_val(l.lines.as_slice()) + + size_of_val(l.frames.as_slice()) + + size_of_val(l.inlines.as_slice()) + + l.markers.iter().map(|m| size_of_val(m) + m.text.len()).sum::() +} + +fn shelf() -> MutexGuard<'static, Shelf> { + static SHELF: LazyLock> = LazyLock::new(Mutex::default); + SHELF.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Keep `layout` of `t`, made from the global font database after `stamp` was taken, for the first +/// frame ([`take_kept_layout`]). `t` is the object as the document holds it. +pub fn keep_layout(t: &TextObject, stamp: LayoutStamp, layout: TextLayout) { + let now = LayoutStamp::now(); + shelf().keep(t, stamp, now, layout, KEPT_LAYOUTS_BUDGET); +} + +/// The layout [`keep_layout`] kept for a text object equal to `t`, when a frame is being drawn +/// ([`KeptLayoutsFrame`]) and the fonts and the hyphenation exceptions have not changed since. Taking +/// it removes it. A thumbnail, which draws outside a frame, leaves the layouts for the first frame. +pub fn take_kept_layout(t: &TextObject) -> Option { + let now = LayoutStamp::now(); + let mut shelf = shelf(); + if shelf.drawing == 0 { + return None; + } + shelf.take(t, now) +} + +/// A frame being drawn. When the last frame being drawn ends, the kept layouts are dropped if a frame +/// took one, or after a run of frames that took none. +pub struct KeptLayoutsFrame(()); + +impl KeptLayoutsFrame { + #[must_use] + pub fn begin() -> Self { + shelf().frame_begin(); + Self(()) + } +} + +impl Drop for KeptLayoutsFrame { + fn drop(&mut self) { + shelf().frame_done(); + } +} + +/// Drop every kept layout (tests in other crates, to lay out again what a frame would have taken). +#[cfg(any(test, feature = "test-fonts"))] +pub(crate) fn drop_kept_layouts() { + shelf().clear(); +} + +#[cfg(test)] +pub(crate) mod test_shelf { + use super::*; + + /// A shelf of its own, so that tests do not share the process's or its font database's stamp. + #[derive(Default)] + pub(crate) struct TestShelf(Shelf); + + impl TestShelf { + pub(crate) fn keep(&mut self, t: &TextObject, stamp: LayoutStamp, now: LayoutStamp, layout: TextLayout, budget: usize) { + self.0.keep(t, stamp, now, layout, budget); + } + pub(crate) fn take(&mut self, t: &TextObject, now: LayoutStamp) -> Option { + self.0.take(t, now) + } + pub(crate) fn frame_begin(&mut self) { + self.0.frame_begin(); + } + pub(crate) fn frame_done(&mut self) { + self.0.frame_done(); + } + pub(crate) fn bytes(&self) -> usize { + self.0.bytes + } + } + + pub(crate) const FRAMES: u32 = KEPT_LAYOUTS_FRAMES; + pub(crate) const PER_HASH: usize = KEPT_LAYOUTS_PER_HASH; + + pub(crate) fn stamp(fonts: u64, system_fallback: bool, hyphenation: u64) -> LayoutStamp { + LayoutStamp { fonts, system_fallback, hyphenation } + } + + pub(crate) fn bytes_of(t: &TextObject, layout: &TextLayout) -> usize { + layout_bytes(layout) + text_bytes(t) + } +} diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 01fef1765..fac55c5d6 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -119,6 +119,8 @@ const SHRINK_PASSES: usize = 11; /// fit, composer, OpenType features). Each run is set at the em size its font height reference /// gives it ([`FontDb::font_heights_resolved`]). pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { + #[cfg(any(test, feature = "test-fonts"))] + crate::test_fonts::count_layout(); let resolved = db.font_heights_resolved(t); let t = &*resolved; let first = layout_once(db, t, opts); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9763ea4b6..8b1c4a63d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -22,6 +22,7 @@ mod fontdb; pub mod hyphen; mod justify; mod kashida; +mod kept; mod layout; mod shape; #[cfg(not(target_arch = "wasm32"))] @@ -43,6 +44,7 @@ pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; use std::sync::Arc; pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; +pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; pub use layout::{decorations, fit_first_line, layout, layout_with, scaled}; pub use vectorcraft_doc::TextObject; diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 69ccbfe70..1f74e2213 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -26,6 +26,25 @@ pub(crate) fn keeps_shape_plans() -> bool { KEEP_SHAPE_PLANS.load(Ordering::SeqCst) } +std::thread_local! { + /// How many layouts this thread has started ([`layouts_on_this_thread`]). + static LAYOUTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(crate) fn count_layout() { + LAYOUTS.with(|n| n.set(n.get() + 1)); +} + +/// How many layouts this thread has started. +pub fn layouts_on_this_thread() -> u64 { + LAYOUTS.with(std::cell::Cell::get) +} + +/// Drop every layout kept for a first frame, so that a test can draw what laying out again draws. +pub fn drop_kept_layouts() { + crate::kept::drop_kept_layouts(); +} + /// The family of [`variable_font`]. pub const VARIABLE_FAMILY: &str = "Varitest Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 031d35bdc..8a3371e8f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -16,6 +16,7 @@ mod hang; mod inline; mod joiners; mod justify; +mod kept_layouts; mod ligature_carets; mod path_joins; mod ruby; diff --git a/crates/text/src/tests/kept_layouts.rs b/crates/text/src/tests/kept_layouts.rs new file mode 100644 index 000000000..e452ab3cf --- /dev/null +++ b/crates/text/src/tests/kept_layouts.rs @@ -0,0 +1,316 @@ +//! Layouts kept for the first frame: found only for an equal text object at the same stamp, taken +//! once, and limited in bytes. + +use kurbo::{Affine, Point}; +use vectorcraft_doc::color::{Color, Gradient, GradientPaint, Paint}; +use vectorcraft_doc::{CharStyle, Dash, RubySpan, TabAlign, TabStop, TextKind, TextObject, TextWrap, WrapShape}; +use vectorcraft_geom::PathData; + +use crate::kept::test_shelf::{FRAMES, PER_HASH, TestShelf, bytes_of, stamp}; +use crate::{FontDb, ListMarker, TextLayout, layout}; + +const BUDGET: usize = 1 << 30; + +fn object(text: &str) -> TextObject { + TextObject::point(Point::ZERO, text, CharStyle::default()) +} + +fn laid_out(t: &TextObject) -> TextLayout { + layout(FontDb::global(), t) +} + +#[test] +fn a_kept_layout_is_taken_once() { + let at = stamp(1, true, 1); + let t = object("Type"); + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + let taken = shelf.take(&t.clone(), at).unwrap(); + assert_eq!(taken.glyphs.len(), 4); + assert!(shelf.take(&t, at).is_none()); + assert_eq!(shelf.bytes(), 0); +} + +#[test] +fn equal_objects_take_a_layout_each() { + let at = stamp(1, true, 1); + let t = object("Type"); + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + assert!(shelf.take(&t, at).is_some() && shelf.take(&t, at).is_some()); + assert!(shelf.take(&t, at).is_none()); +} + +#[test] +fn a_changed_object_does_not_find_the_layout() { + let at = stamp(1, true, 1); + let t = object("Type"); + fn frame(w: f64) -> TextKind { + TextKind::Area { frame: PathData::from_bezpath(&kurbo::Shape::to_path(&kurbo::Rect::new(0.0, 0.0, w, 50.0), 0.1)) } + } + let changes: [fn(&mut TextObject); 11] = [ + |t| t.runs[0].text = "Typo".into(), + |t| t.runs[0].style.size += 1.0, + |t| t.runs[0].style.font_family = "Inter".into(), + |t| t.runs[0].style.font_style = "Bold".into(), + |t| t.runs[0].style.language = Some("tr".into()), + |t| t.xf = Affine::translate((1.0, 0.0)), + |t| t.vertical = true, + |t| t.para.first_line_indent = 3.0, + |t| t.first_para_continues = true, + |t| t.area.columns = 2, + |t| t.kind = frame(100.0), + ]; + for (i, change) in changes.iter().enumerate() { + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + let mut other = t.clone(); + change(&mut other); + assert!(shelf.take(&other, at).is_none(), "change {i}"); + assert!(shelf.take(&t, at).is_some(), "change {i}: the object itself still finds it"); + } + // Two frame shapes of the same size, each a text object of its own. + let mut area = t.clone(); + area.kind = frame(100.0); + let mut wider = t.clone(); + wider.kind = frame(120.0); + let mut shelf = TestShelf::default(); + shelf.keep(&area, at, at, laid_out(&area), BUDGET); + assert!(shelf.take(&wider, at).is_none()); + assert!(shelf.take(&area, at).is_some()); +} + +#[test] +fn a_changed_stamp_drops_the_layouts() { + let t = object("Type"); + let at = stamp(1, true, 1); + for later in [stamp(2, true, 1), stamp(1, false, 1), stamp(1, true, 2)] { + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + assert!(shelf.take(&t, later).is_none(), "{later:?}"); + assert_eq!(shelf.bytes(), 0, "{later:?}"); + assert!(shelf.take(&t, at).is_none(), "{later:?}: gone for good"); + } +} + +#[test] +fn a_layout_made_while_the_stamp_changed_is_not_kept() { + let t = object("Type"); + let mut shelf = TestShelf::default(); + shelf.keep(&t, stamp(1, true, 1), stamp(2, true, 1), laid_out(&t), BUDGET); + assert_eq!(shelf.bytes(), 0); + assert!(shelf.take(&t, stamp(1, true, 1)).is_none() && shelf.take(&t, stamp(2, true, 1)).is_none()); +} + +#[test] +fn keeping_at_a_new_stamp_lets_the_older_layouts_go() { + let (old, new) = (stamp(1, true, 1), stamp(2, true, 1)); + let (a, b) = (object("Old"), object("New")); + let mut shelf = TestShelf::default(); + shelf.keep(&a, old, old, laid_out(&a), BUDGET); + shelf.keep(&b, new, new, laid_out(&b), BUDGET); + assert!(shelf.take(&a, new).is_none()); + assert!(shelf.take(&b, new).is_some()); +} + +#[test] +fn the_budget_lets_the_oldest_layouts_go() { + let at = stamp(1, true, 1); + let (a, b, c) = (object("Abc"), object("Def"), object("Ghi")); + let (la, lb, lc) = (laid_out(&a), laid_out(&b), laid_out(&c)); + let budget = bytes_of(&b, &lb) + bytes_of(&c, &lc); + assert!(bytes_of(&a, &la) + budget > budget); + let mut shelf = TestShelf::default(); + shelf.keep(&a, at, at, la, budget); + shelf.keep(&b, at, at, lb, budget); + shelf.keep(&c, at, at, lc, budget); + assert!(shelf.bytes() <= budget); + assert!(shelf.take(&a, at).is_none(), "the oldest went"); + assert!(shelf.take(&b, at).is_some() && shelf.take(&c, at).is_some()); + assert_eq!(shelf.bytes(), 0); +} + +#[test] +fn a_layout_larger_than_the_budget_is_not_kept_and_lets_nothing_go() { + let at = stamp(1, true, 1); + let (small, large) = (object("A"), object(&"Long text ".repeat(50))); + let (ls, ll) = (laid_out(&small), laid_out(&large)); + let budget = bytes_of(&small, &ls); + assert!(bytes_of(&large, &ll) > budget); + let mut shelf = TestShelf::default(); + shelf.keep(&small, at, at, ls, budget); + shelf.keep(&large, at, at, ll, budget); + assert!(shelf.take(&large, at).is_none()); + assert!(shelf.take(&small, at).is_some()); +} + +#[test] +fn a_frame_that_took_a_layout_lets_the_others_go() { + let at = stamp(1, true, 1); + let (a, b) = (object("Seen"), object("Out of view")); + let mut shelf = TestShelf::default(); + shelf.keep(&a, at, at, laid_out(&a), BUDGET); + shelf.keep(&b, at, at, laid_out(&b), BUDGET); + shelf.frame_done(); + assert!(shelf.bytes() > 0, "a frame that took none lets none go"); + assert!(shelf.take(&a, at).is_some()); + shelf.frame_done(); + assert_eq!(shelf.bytes(), 0); + assert!(shelf.take(&b, at).is_none()); +} + +#[test] +fn a_layout_with_color_art_is_not_kept() { + let at = stamp(1, true, 1); + let t = object("Type"); + let mut l = laid_out(&t); + l.glyphs[0].colour = Some(std::sync::Arc::new(vectorcraft_doc::Node::group(vectorcraft_doc::NodeId(1), vec![]))); + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, l, BUDGET); + assert_eq!(shelf.bytes(), 0); + assert!(shelf.take(&t, at).is_none()); +} + +#[test] +fn an_object_with_a_gradient_is_not_kept() { + let at = stamp(1, true, 1); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + for stroke in [false, true] { + let mut t = object("Type"); + if stroke { + t.runs[0].style.stroke = gradient.clone(); + } else { + t.runs[0].style.fill = gradient.clone(); + } + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + assert_eq!(shelf.bytes(), 0, "stroke: {stroke}"); + } +} + +/// Objects that differ only past the hash stay few enough to compare. +#[test] +fn one_hash_holds_a_limited_number_of_layouts() { + let at = stamp(1, true, 1); + let objects: Vec = (0..PER_HASH + 2) + .map(|i| { + let mut t = object("Type"); + t.para.first_line_indent = i as f64; + t + }) + .collect(); + let mut shelf = TestShelf::default(); + for t in &objects { + shelf.keep(t, at, at, laid_out(t), BUDGET); + } + let taken = objects.iter().filter(|t| shelf.take(t, at).is_some()).count(); + assert_eq!(taken, PER_HASH); + assert_eq!(shelf.bytes(), 0); +} + +#[test] +fn a_frame_still_being_drawn_keeps_the_layouts_it_may_take() { + let at = stamp(1, true, 1); + let (a, b) = (object("Canvas"), object("Other pane")); + let mut shelf = TestShelf::default(); + shelf.keep(&a, at, at, laid_out(&a), BUDGET); + shelf.keep(&b, at, at, laid_out(&b), BUDGET); + shelf.frame_begin(); + shelf.frame_begin(); + assert!(shelf.take(&a, at).is_some()); + shelf.frame_done(); + assert!(shelf.take(&b, at).is_some(), "the other frame takes its layout after the first ends"); + shelf.keep(&a, at, at, laid_out(&a), BUDGET); + shelf.frame_done(); + assert_eq!(shelf.bytes(), 0, "the last frame to end lets the rest go"); +} + +#[test] +fn frames_that_take_nothing_let_the_layouts_go() { + let at = stamp(1, true, 1); + let t = object("Out of view"); + let mut shelf = TestShelf::default(); + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + for _ in 1..FRAMES { + shelf.frame_done(); + } + assert!(shelf.bytes() > 0, "the last frame has not ended"); + // Another open starts the count again. + shelf.keep(&t, at, at, laid_out(&t), BUDGET); + shelf.frame_done(); + assert!(shelf.bytes() > 0, "a keep starts the count again"); + for _ in 1..FRAMES { + shelf.frame_done(); + } + assert_eq!(shelf.bytes(), 0); + assert!(shelf.take(&t, at).is_none()); +} + +#[test] +fn the_budget_counts_frames_paths_and_markers() { + let t = object("Type"); + let mut l = laid_out(&t); + let plain = bytes_of(&t, &l); + l.frames = vec![kurbo::Rect::ZERO; 1000]; + let with_frames = bytes_of(&t, &l); + l.markers = vec![ListMarker { glyphs: 0..0, tab: 0, text: "x".repeat(1000) }]; + let with_markers = bytes_of(&t, &l); + let mut area = t.clone(); + let wide = kurbo::BezPath::from_vec( + std::iter::once(kurbo::PathEl::MoveTo(kurbo::Point::ZERO)) + .chain((1..1000).map(|i| kurbo::PathEl::LineTo(kurbo::Point::new(f64::from(i), 0.0)))) + .collect(), + ); + area.kind = TextKind::Area { frame: PathData::from_bezpath(&wide) }; + let mut wrapped = t.clone(); + wrapped.wrap = vec![WrapShape { path: PathData::from_bezpath(&wide), wrap: TextWrap::default() }]; + let mut paragraphs = t.clone(); + paragraphs.paras = vec![paragraphs.para.clone(); 100]; + let mut styled = t.clone(); + styled.runs[0].style.font_family = "x".repeat(1000); + let mut featured = t.clone(); + featured.runs[0].style.features = vec!["x".repeat(1000)]; + let mut swatched = t.clone(); + swatched.runs[0].style.fill = Paint::Solid { color: Color::BLACK, swatch: Some("x".repeat(1000)), tint: 1.0 }; + let mut dashed = t.clone(); + dashed.runs[0].style.stroke_dash = Some(Dash { pattern: vec![1.0; 1000], ..Dash::default() }); + let mut tabbed = t.clone(); + tabbed.para.tabs = vec![TabStop { position: 10.0, align: TabAlign::default(), leader: "x".repeat(1000), align_on: '.' }]; + let mut with_ruby = t.clone(); + with_ruby.ruby = vec![RubySpan::new(0, 1, "x".repeat(1000))]; + let mut with_carets = laid_out(&t); + let no_carets = bytes_of(&t, &with_carets); + with_carets.glyphs[0].carets = vec![(0, 0.0); 1000]; + let anchors = 999 * size_of::(); + let counted = [ + ("frames", with_frames >= plain + 1000 * size_of::()), + ("markers", with_markers >= with_frames + 1000), + ("the frame's path", bytes_of(&area, &l) >= with_markers + anchors), + ("the wrap's path", bytes_of(&wrapped, &l) >= with_markers + anchors), + ("paragraphs", bytes_of(¶graphs, &l) >= with_markers + 100 * size_of::()), + ("a run's family", bytes_of(&styled, &l) >= with_markers + 900), + ("a run's features", bytes_of(&featured, &l) >= with_markers + 1000), + ("a swatch's name", bytes_of(&swatched, &l) >= with_markers + 1000), + ("a stroke's dashes", bytes_of(&dashed, &l) >= with_markers + 1000 * size_of::()), + ("a tab's leader", bytes_of(&tabbed, &l) >= with_markers + 1000), + ("ruby text", bytes_of(&with_ruby, &l) >= with_markers + 1000), + ("carets", bytes_of(&t, &with_carets) >= no_carets + 1000 * size_of::<(usize, f64)>()), + ]; + let missed: Vec<&str> = counted.iter().filter(|(_, ok)| !ok).map(|(name, _)| *name).collect(); + assert!(missed.is_empty(), "not counted: {missed:?}"); +} + +#[test] +fn a_taken_layout_stays_within_the_budget_after_many_keeps() { + // Taken layouts leave nothing behind that a later keep has to count. + let at = stamp(1, true, 1); + let t = object("Type"); + let budget = bytes_of(&t, &laid_out(&t)); + let mut shelf = TestShelf::default(); + for _ in 0..50 { + shelf.keep(&t, at, at, laid_out(&t), budget); + assert!(shelf.take(&t, at).is_some()); + assert_eq!(shelf.bytes(), 0); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3931636dc..a082d30b5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1354,6 +1354,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). + Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200 with other builds running, in medians of interleaved rounds of a local example, with 5,000 point type objects in view the first frame takes 242 ms instead of 280 ms (103 ms instead of 212 ms in the fastest round) and the open takes 237 ms instead of 178 ms (170 ms instead of 143 ms); with one area type object of 500 paragraphs the first frame takes 211 ms instead of 464 ms. Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460). The canvas keeps the selected objects' outlines, anchors and handles from one frame to the next as one mesh. It builds an object's marks again when the object, its layer color or its selected anchors change. A pan moves the kept outlines and handles and places the anchor squares again. Each subpath and handle gets its triangles the first time it is in view. A frame with a new zoom or rotation draws the marks one shape at a time and keeps nothing, and the next frame at that view builds them. The pixels are the same, apart from two small differences: a kept outline's corner of exactly 90 degrees in a rotated view can keep its miter or cut corner after a pan, and kept vertices can move by rounding errors. On Windows at 1440 × 900, in a release build, with 2,000 selected circles of 50 anchors each in 10 fill colors, an idle frame takes 12.8 ms instead of 59.9 ms with the Selection tool and 29.8 ms instead of 192.1 ms with Direct Selection (medians of five runs). A frame during a zoom takes about what it did: 86.7 ms instead of 80.0 ms with the Selection tool. From f4ccca02d0be0ba3b7039807b43d3e8efb0d1588 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 22:11:05 -0800 Subject: [PATCH 44/52] Docs: the stroke outline reuse line is listed once The progress doc listed the moved-path stroke outline reuse twice, once with the timings first measured and once with the timings measured against current main. The line with the earlier timings is removed. --- docs/target-app-parity.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ea7d3bc2..3931636dc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1352,7 +1352,6 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When anchors of a path or compound path of 1,024 anchors or more are added or deleted, or a subpath is closed or opened, the next frame writes again only the path elements from the first anchor that changed to the last, and the closing segment when an end anchor changed or the subpath was closed or opened. It measures again only the runs of segments that held them; a run grows or shrinks with the segments added or deleted, and one that grows past 256 segments is split. When a member gains or loses subpaths, the renderer makes the path of that member again. When a compound path gains or loses members, it makes the path of those members and of every member between the first and the last of them again. An edit that adds or deletes anchors in, closes or opens more than 16 subpaths makes the whole path again. The pixels and the cull bounds are the same. The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. - When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 128 ms instead of 405 ms with an open path of 200,000 anchors in view (104 ms with nothing changed), and 14 ms instead of 28 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. From a18364e2b4ec2493aca0722a0f46f1a7bb2e98ab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 22:05:19 -0800 Subject: [PATCH 45/52] Menus: the wasm build has no unused import warning for menu_open The only callers of `menus::menu_open` are the native palette-window code in `panel_docking.rs` and its native tests, so on wasm32 the re-export and the function were unused and `cargo xtask wasm` printed two warnings for them. Both now carry `#[cfg(not(target_arch = "wasm32"))]`. The menu bar still records whether a menu is open on every target. --- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/menus/bar.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dff97c9a5..3d3f2a8c0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -58,6 +58,7 @@ mod tree; mod ui_commands; pub use bar::menu_bar; +#[cfg(not(target_arch = "wasm32"))] pub(crate) use bar::menu_open; pub(crate) use bar::{home_key, home_showing}; #[cfg(test)] diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 1d8d00197..c57a21637 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -68,6 +68,7 @@ fn menu_open_id() -> egui::Id { } /// Whether a menu of the in-window menu bar is open (`false` when no menu bar was drawn yet). +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn menu_open(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(menu_open_id())).unwrap_or(false) } From 4cf4ea3a3921aa3047bae1b2a8dcf0b3d9824be5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 22:07:33 -0800 Subject: [PATCH 46/52] Tests: the shape builder and large edit guards count work instead of timing it The Shape Builder, Live Paint and run_around tests in planar/tests.rs measured elapsed time against a 5 s limit, and the large edit tests (large_edits.rs, with the Lock and Hide All Artwork Above and perspective Alt-drag tests that share `soon`) against 20 s. On a loaded machine they failed without any change in the code. - pathops: a test-only counter adds up the items `RectIndex::at` reads and the segments `nearest_u` searches. The four planar tests assert a bound on that count. - doc: `tree_walks` counts, per thread, the walks of a document's tree that lookups by id make. `soon` fails when a command walks the tree more than 64 times, and keeps a 300 s wait for a hang. The tests that named a 20 s limit are renamed. Looking objects up one at a time in Delete or Offset Path, or scanning the whole index or ring in the planar code, makes these tests fail. --- crates/doc/src/document.rs | 2 + crates/doc/src/id_index.rs | 19 ++++++++ crates/doc/src/lib.rs | 1 + crates/engine/src/tests/large_edits.rs | 56 ++++++++++++---------- crates/engine/src/tests/menucmds.rs | 11 +++-- crates/engine/src/tests/xform.rs | 2 +- crates/pathops/src/planar.rs | 2 +- crates/pathops/src/planar/index.rs | 21 ++++++++ crates/pathops/src/planar/shape_builder.rs | 2 + crates/pathops/src/planar/tests.rs | 23 +++++---- 10 files changed, 100 insertions(+), 39 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 102caf53d..78f90dac6 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -352,6 +352,7 @@ impl Document { } false } + crate::id_index::note_tree_walk(); let mut p = vec![]; find(&self.layers, id, &mut p).then_some(p) } @@ -393,6 +394,7 @@ impl Document { }); } if !want.is_empty() { + crate::id_index::note_tree_walk(); let mut rest = HashMap::with_capacity(want.len()); walk(&self.layers, &want, &mut Vec::new(), &mut rest); out.extend(rest); diff --git a/crates/doc/src/id_index.rs b/crates/doc/src/id_index.rs index 65a6962f1..ed89f6ead 100644 --- a/crates/doc/src/id_index.rs +++ b/crates/doc/src/id_index.rs @@ -90,6 +90,24 @@ impl Paths { /// object and look the next one up, again and again). const MISSES_PER_REBUILD: u32 = 4; +thread_local! { + /// [`tree_walks`] of this thread. + static TREE_WALKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times lookups by id on this thread have walked a document's tree, reading up to all of +/// its nodes: one count per search of the tree and per rebuild of the cache. An edit that looks its +/// objects up one at a time counts about one walk per object; one that looks them all up at once +/// counts a few. +pub fn tree_walks() -> u64 { + TREE_WALKS.get() +} + +/// Adds one to [`tree_walks`]. +pub(crate) fn note_tree_walk() { + TREE_WALKS.set(TREE_WALKS.get().saturating_add(1)); +} + /// The cache, shared by clones of a document (each checks every path it uses against its own tree). #[derive(Default)] pub struct IdIndex(RwLock>>, std::sync::atomic::AtomicU32); @@ -125,6 +143,7 @@ impl IdIndex { } } } + note_tree_walk(); let mut out = Paths::default(); walk(layers, u32::MAX, &mut out); let out = Arc::new(out); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6d069872d..bff8e40f3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -19,6 +19,7 @@ pub mod graph; mod halftone_radii; pub mod hit; mod id_index; +pub use id_index::tree_walks; pub mod inks; mod inline; pub mod intertwine; diff --git a/crates/engine/src/tests/large_edits.rs b/crates/engine/src/tests/large_edits.rs index 97405d6f6..aa18cef1e 100644 --- a/crates/engine/src/tests/large_edits.rs +++ b/crates/engine/src/tests/large_edits.rs @@ -1,6 +1,6 @@ -//! Commands that remove, insert or copy many objects, and an Alt-drag that copies them, each finish -//! within 20 s on one layer of 25,000 to 100,000 objects, and objects in two layers and inside a -//! group keep their order and ids. +//! Commands that remove, insert or copy many objects, and an Alt-drag that copies them, each walk +//! the document's tree a few times on one layer of 25,000 to 100,000 objects, and objects in two +//! layers and inside a group keep their order and ids. use std::sync::Arc; use std::time::Duration; @@ -65,14 +65,22 @@ fn in_turn(a: &[NodeId], b: &[NodeId]) -> Vec { a.iter().zip(b).flat_map(|(a, b)| [*a, *b]).collect() } -/// Runs `f` on `s` on a thread of its own and fails the test when it takes more than 20 s. +/// Most walks of the document's tree ([`vectorcraft_doc::tree_walks`]) that [`soon`] allows, however +/// many objects the document holds. A command that looks its objects up one at a time walks the tree +/// about once per object. +const MOST_TREE_WALKS: u64 = 64; + +/// Runs `f` on `s` on a thread of its own and fails the test when it walks the document's tree more +/// than [`MOST_TREE_WALKS`] times or has not ended after 300 s. pub(crate) fn soon(mut s: Session, what: &'static str, f: impl FnOnce(&mut Session) -> T + Send + 'static) -> (Session, T) { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let r = f(&mut s); - tx.send((s, r)).ok(); + tx.send((s, r, vectorcraft_doc::tree_walks())).ok(); }); - rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("{what} did not finish within 20 s")) + let (s, r, walks) = rx.recv_timeout(Duration::from_secs(300)).unwrap_or_else(|_| panic!("{what} did not finish within 300 s")); + assert!(walks <= MOST_TREE_WALKS, "{what} walked the document's tree {walks} times"); + (s, r) } /// Runs command `cmd` with `params` through [`soon`] and returns its result. @@ -103,7 +111,7 @@ fn bounds(s: &Session, id: NodeId) -> Rect { } #[test] -fn delete_of_100000_objects_finishes_within_20_s() { +fn delete_of_100000_objects_takes_few_tree_walks() { let s = session(100_000, square); let before = s.doc().unwrap().doc.clone(); let (mut s, _) = run_soon(s, "edit.clear", json!({})); @@ -113,7 +121,7 @@ fn delete_of_100000_objects_finishes_within_20_s() { } #[test] -fn delete_selection_of_100000_rows_finishes_within_20_s() { +fn delete_selection_of_100000_rows_takes_few_tree_walks() { let s = session(100_000, square); let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0)); let (mut s, r) = run_soon(s, "layer.delete", json!({"ids": ids})); @@ -124,7 +132,7 @@ fn delete_selection_of_100000_rows_finishes_within_20_s() { } #[test] -fn clean_up_of_100000_unpainted_paths_finishes_within_20_s() { +fn clean_up_of_100000_unpainted_paths_takes_few_tree_walks() { let s = session(100_000, |d, k| Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default())); let before = s.doc().unwrap().doc.clone(); let (mut s, r) = run_soon(s, "object.path.cleanUp", json!({})); @@ -135,7 +143,7 @@ fn clean_up_of_100000_unpainted_paths_finishes_within_20_s() { } #[test] -fn group_of_50000_objects_finishes_within_20_s() { +fn group_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0)); let (mut s, r) = run_soon(s, "object.group", json!({})); @@ -147,7 +155,7 @@ fn group_of_50000_objects_finishes_within_20_s() { } #[test] -fn clipping_mask_of_50000_objects_finishes_within_20_s() { +fn clipping_mask_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0)); let (mut s, r) = run_soon(s, "object.clippingMask.make", json!({})); @@ -165,7 +173,7 @@ fn clipping_mask_of_50000_objects_finishes_within_20_s() { } #[test] -fn duplicate_of_50000_objects_finishes_within_20_s() { +fn duplicate_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "edit.duplicate", json!({"dx": 5})); @@ -179,7 +187,7 @@ fn duplicate_of_50000_objects_finishes_within_20_s() { } #[test] -fn transform_with_copy_of_50000_objects_finishes_within_20_s() { +fn transform_with_copy_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})); @@ -194,7 +202,7 @@ fn transform_with_copy_of_50000_objects_finishes_within_20_s() { } #[test] -fn transform_each_with_copy_of_50000_objects_finishes_within_20_s() { +fn transform_each_with_copy_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "object.transformEach", json!({"moveH": 5, "copy": true})); @@ -207,7 +215,7 @@ fn transform_each_with_copy_of_50000_objects_finishes_within_20_s() { } #[test] -fn offset_path_of_50000_objects_finishes_within_20_s() { +fn offset_path_of_50000_objects_takes_few_tree_walks() { let s = session(50_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "object.path.offsetPath", json!({"offset": 1})); @@ -222,7 +230,7 @@ fn offset_path_of_50000_objects_finishes_within_20_s() { } #[test] -fn compound_path_of_100000_objects_finishes_within_20_s() { +fn compound_path_of_100000_objects_takes_few_tree_walks() { let s = session(100_000, square); let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0)); let (mut s, r) = run_soon(s, "object.compoundPath.make", json!({})); @@ -234,7 +242,7 @@ fn compound_path_of_100000_objects_finishes_within_20_s() { } #[test] -fn send_to_current_layer_of_100000_objects_finishes_within_20_s() { +fn send_to_current_layer_of_100000_objects_takes_few_tree_walks() { let mut s = session(100_000, square); let ids = layer(&s, 0); let to = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); @@ -249,7 +257,7 @@ fn send_to_current_layer_of_100000_objects_finishes_within_20_s() { } #[test] -fn row_drag_of_100000_rows_finishes_within_20_s() { +fn row_drag_of_100000_rows_takes_few_tree_walks() { let mut s = session(100_000, square); let ids = layer(&s, 0); let to = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); @@ -263,7 +271,7 @@ fn row_drag_of_100000_rows_finishes_within_20_s() { } #[test] -fn merge_selected_of_100000_objects_finishes_within_20_s() { +fn merge_selected_of_100000_objects_takes_few_tree_walks() { let mut s = session(100_000, square); let (from, ids) = (s.doc().unwrap().doc.layers[0].id, layer(&s, 0)); let into = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); @@ -277,7 +285,7 @@ fn merge_selected_of_100000_objects_finishes_within_20_s() { } #[test] -fn ungroup_of_25000_groups_finishes_within_20_s() { +fn ungroup_of_25000_groups_takes_few_tree_walks() { // Each group holds two squares, and ungrouping one adds an object to the layer. let s = session(25_000, |d, k| { let pair = [square(d, 2 * k), square(d, 2 * k + 1)].map(Arc::new); @@ -292,7 +300,7 @@ fn ungroup_of_25000_groups_finishes_within_20_s() { } #[test] -fn compound_path_release_of_25000_compound_paths_finishes_within_20_s() { +fn compound_path_release_of_25000_compound_paths_takes_few_tree_walks() { let s = session(25_000, |d, k| { let pair = [square(d, 2 * k), square(d, 2 * k + 1)].map(Arc::new); Node::new(d.alloc_id(), NodeKind::Compound { children: pair.to_vec(), rule: FillRule::NonZero }) @@ -306,7 +314,7 @@ fn compound_path_release_of_25000_compound_paths_finishes_within_20_s() { } #[test] -fn release_to_layers_of_100000_objects_finishes_within_20_s() { +fn release_to_layers_of_100000_objects_takes_few_tree_walks() { let s = session(100_000, square); let doc = &s.doc().unwrap().doc; let (before, l, ids, first) = (doc.clone(), doc.layers[0].id, layer(&s, 0), doc.peek_next_id()); @@ -329,7 +337,7 @@ fn release_to_layers_of_100000_objects_finishes_within_20_s() { } #[test] -fn outline_stroke_of_25000_objects_finishes_within_20_s() { +fn outline_stroke_of_25000_objects_takes_few_tree_walks() { let s = session(25_000, square); let (before, first) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().doc.peek_next_id()); let (mut s, r) = run_soon(s, "object.path.outlineStroke", json!({})); @@ -347,7 +355,7 @@ fn outline_stroke_of_25000_objects_finishes_within_20_s() { /// With Alt held, a Selection tool press on one of 30,000 selected squares, four moves of 20 pt /// and the release copy the squares once, as one undo step. #[test] -fn alt_drag_of_30000_objects_finishes_within_20_s() { +fn alt_drag_of_30000_objects_takes_few_tree_walks() { let s = session(30_000, square); let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id()); // The press lands on a square in the middle of the art, away from the handles of the diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 173303e9d..7d406a0c3 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -257,10 +257,10 @@ fn tiles_under_squares() -> (Session, Vec, Vec) { } /// With 1,000 tiles selected under 50,000 squares, Lock › All Artwork Above locks every square in one -/// undo step and Hide › All Artwork Above hides every square, each within 20 s, and the tiles stay -/// selected. +/// undo step and Hide › All Artwork Above hides every square, each in a few walks of the tree, and +/// the tiles stay selected. #[test] -fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_finish_within_20_s() { +fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_take_few_tree_walks() { let (mut s, tiles, squares) = tiles_under_squares(); sel(&mut s, &tiles); let before = s.doc().unwrap().doc.clone(); @@ -280,9 +280,10 @@ fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_finish_within_ } /// With all 51,000 objects of [`tiles_under_squares`] selected, Lock › All Artwork Above and Hide › -/// All Artwork Above each find nothing to lock or hide within 20 s and record no undo step. +/// All Artwork Above each find nothing to lock or hide in a few walks of the tree and record no undo +/// step. #[test] -fn lock_and_hide_all_artwork_above_with_51000_objects_selected_finish_within_20_s() { +fn lock_and_hide_all_artwork_above_with_51000_objects_selected_take_few_tree_walks() { let (s, tiles, squares) = tiles_under_squares(); let (steps, all) = (undo_len(&s), [tiles, squares].concat()); assert!(selected(&s) == all, "everything is selected"); diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index ce2d3a3e7..4de6abd39 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -407,7 +407,7 @@ fn an_alt_drag_in_perspective_on_a_grid_from_an_older_file_attaches_the_copies() /// first one kept, and the drag leaves what one `perspective.move` with copy leaves, as one undo /// step. #[test] -fn alt_drag_in_perspective_of_30000_objects_finishes_within_20_s() { +fn alt_drag_in_perspective_of_30000_objects_takes_few_tree_walks() { // The squares are 6 pt wide and 10 pt apart, in rows of 150, between the left vanishing point // of the two-point grid and its origin. let on_the_left = |d: &mut Document, k: usize| { diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index 02e8cc346..75234b394 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -34,7 +34,7 @@ mod shape_builder; pub(crate) use graph::pin_ends; #[cfg(test)] -use index::{Crossings, RectIndex, reference_budget, segment_footprint}; +use index::{Crossings, RectIndex, reference_budget, segment_footprint, work_done}; pub use live_paint::{find_gaps, live_paint, live_paint_with_gaps}; pub use map::interior_point; pub(crate) use map::{Map, Rest, map}; diff --git a/crates/pathops/src/planar/index.rs b/crates/pathops/src/planar/index.rs index 1b59f829c..7d709e80d 100644 --- a/crates/pathops/src/planar/index.rs +++ b/crates/pathops/src/planar/index.rs @@ -61,6 +61,25 @@ impl WindingNumber for Crossings { } } +#[cfg(test)] +thread_local! { + /// [`work_done`] of this thread. + static WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many items [`RectIndex::at`] has read and segments [`run_around`](super::run_around) has +/// searched for the nearest point on a whole piece, on this thread. +#[cfg(test)] +pub(super) fn work_done() -> u64 { + WORK.get() +} + +/// Adds `n` to [`work_done`]. +#[cfg(test)] +pub(super) fn note_work(n: usize) { + WORK.set(WORK.get().saturating_add(n as u64)); +} + /// A point in the middle of a run of segments. pub(super) fn mid(segs: &[Seg]) -> Option { segs.get(segs.len() / 2).map(|s| s.c.eval(0.5)) @@ -202,6 +221,8 @@ impl RectIndex { } found.sort_unstable(); found.dedup(); + #[cfg(test)] + note_work(found.len()); found.into_iter().filter_map(move |i| self.items.get(i)).filter(move |(r, _)| r.inflate(tol, tol).contains(p)).map(|(_, t)| t) } } diff --git a/crates/pathops/src/planar/shape_builder.rs b/crates/pathops/src/planar/shape_builder.rs index fde156f9b..fcfb6cea9 100644 --- a/crates/pathops/src/planar/shape_builder.rs +++ b/crates/pathops/src/planar/shape_builder.rs @@ -274,6 +274,8 @@ pub fn run_around(piece: &SubPath, stops: &[Point], at: Point, tol: f64) -> Opti /// The point of `sp` nearest `p`, as a parameter `segment + t`, with its distance. fn nearest_u(sp: &SubPath, p: Point) -> Option<(f64, f64)> { use kurbo::ParamCurveNearest; + #[cfg(test)] + super::index::note_work(sp.segment_count()); (0..sp.segment_count()) .map(|k| { let n = sp.segment(k).nearest(p, 1e-9); diff --git a/crates/pathops/src/planar/tests.rs b/crates/pathops/src/planar/tests.rs index 3cee370df..025d760f4 100644 --- a/crates/pathops/src/planar/tests.rs +++ b/crates/pathops/src/planar/tests.rs @@ -606,18 +606,23 @@ fn shape_builder_stays_quick_up_to_its_cap() { .collect(); let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)]; // Closed paths alone are swept for the gaps they enclose. - let started = std::time::Instant::now(); + let started = work_done(); assert_eq!(shape_builder(&s, false).regions.len(), 1, "the ring encloses no gap"); - assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + // Reads of indexed items stay in proportion to the ring. Scanning the ring for each edge's + // source would read about `n` items per edge. + let most = 64 * n as u64; + let work = work_done() - started; + assert!(work <= most, "{work} reads"); for k in 0..4 { let y = -60.0 + 40.0 * f64::from(k); s.push(line((-150.0, y), (150.0, y + 7.0), 1)); } - let started = std::time::Instant::now(); + let started = work_done(); let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); assert!(regions.len() >= 5, "the ring cut in five bands at least (the waves make pockets)"); assert!(lines.len() >= 12, "{}", lines.len()); - assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + let work = work_done() - started; + assert!(work <= most, "{work} reads"); // Past the cap, only the filled areas count. s.push(line((0.0, 0.0), (1.0, 1.0), 2)); s[0].path.subpaths[0].anchors.extend((0..16).map(|k| vectorcraft_geom::Anchor::corner(Point::new(100.0, f64::from(k))))); @@ -966,12 +971,13 @@ fn run_around_a_ring_of_4000_sides_measures_only_the_stops_it_reaches() { .collect(); let ring = poly(&pts, true); let stops: Vec = ring.anchors.iter().map(|a| a.p).collect(); - let start = std::time::Instant::now(); + let started = work_done(); for k in (0..n).step_by(200) { let run = run_around(&ring, &stops, ring.segment(k).eval(0.5), 1e-3).expect("a side"); assert_eq!(ends(&run), (stops[k], stops[(k + 1) % n]), "side {k}"); } - assert!(start.elapsed().as_secs_f64() < 5.0, "20 runs took {:?}", start.elapsed()); + let work = work_done() - started; + assert!(work <= 1_000_000, "20 runs made {work} searches"); } /// Proptest shrank a failure of `pieces_cut_out_of_their_inputs` to this input with linesweeper @@ -1095,10 +1101,11 @@ fn many_lines_stay_quick() { line((50.0 + 80.0 * t.cos(), 50.0 + 80.0 * t.sin()), (50.0 - 80.0 * (t + 1.1).cos(), 50.0 - 80.0 * (t + 1.1).sin()), 0) }) .collect(); - let started = std::time::Instant::now(); + let started = work_done(); let (faces, _) = live_paint(&lines); assert!(faces.len() > 500, "{}", faces.len()); - assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + let work = work_done() - started; + assert!(work <= 150_000, "{work} reads"); } #[test] From 003f709eb239e816ebfc3a2a2bf5904af77d9609 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 22:28:23 -0800 Subject: [PATCH 47/52] Docs: give the open's kept-layout timings in CPU cycles on current main --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a082d30b5..0bed907fa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1354,7 +1354,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). - Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200 with other builds running, in medians of interleaved rounds of a local example, with 5,000 point type objects in view the first frame takes 242 ms instead of 280 ms (103 ms instead of 212 ms in the fastest round) and the open takes 237 ms instead of 178 ms (170 ms instead of 143 ms); with one area type object of 500 paragraphs the first frame takes 211 ms instead of 464 ms. + Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million. Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460). The canvas keeps the selected objects' outlines, anchors and handles from one frame to the next as one mesh. It builds an object's marks again when the object, its layer color or its selected anchors change. A pan moves the kept outlines and handles and places the anchor squares again. Each subpath and handle gets its triangles the first time it is in view. A frame with a new zoom or rotation draws the marks one shape at a time and keeps nothing, and the next frame at that view builds them. The pixels are the same, apart from two small differences: a kept outline's corner of exactly 90 degrees in a rotated view can keep its miter or cut corner after a pan, and kept vertices can move by rounding errors. On Windows at 1440 × 900, in a release build, with 2,000 selected circles of 50 anchors each in 10 fill colors, an idle frame takes 12.8 ms instead of 59.9 ms with the Selection tool and 29.8 ms instead of 192.1 ms with Direct Selection (medians of five runs). A frame during a zoom takes about what it did: 86.7 ms instead of 80.0 ms with the Selection tool. From d2e58a36a4ba6e1b5655a36a079652b5fabc0140 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 22:47:51 -0800 Subject: [PATCH 48/52] i18n: Turkish error messages keep the parameter keys they name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turkish rows translated backticked parameter keys and command ids, so a message named a key the command does not accept: `stops` read as "duraklar", `ids` as "kimlikler", `aspect` as "boyut", and `plugin.list` as "eklenti.liste". Each of these rows now keeps the key in backticks as the English has it. The rows that list accepted values ("corner", "merge", first, previous, next, last, grayscale, websafe, true, false) keep the values too, and the gradient stop rows read "stop" as a gradient stop rather than the verb. Seven Bulgarian rows quoted a placeholder with „“ where the English and the rest of the catalog use backticks; they now use backticks. A new test checks that every translated row in every catalog keeps each backticked span of its English text. --- crates/ui-egui/src/i18n/bg.tsv | 14 ++--- crates/ui-egui/src/i18n/tests.rs | 41 ++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 94 ++++++++++++++++---------------- 3 files changed, 95 insertions(+), 54 deletions(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 0da4c8b03..fdfd7c713 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4163,13 +4163,13 @@ axis Both И двете @msg no point {i} (the gradient has {_1}) няма точка {i} (градиентът има {_1}) @msg no preset editor няма редактор на предварителни настройки @msg no previous transform няма предишно трансформиране -@msg no print preset named `{base}` няма предварителна настройка за печат с име „{base}“ -@msg no print preset named `{name}` няма предварителна настройка за печат с име „{name}“ +@msg no print preset named `{base}` няма предварителна настройка за печат с име `{base}` +@msg no print preset named `{name}` няма предварителна настройка за печат с име `{name}` @msg no print service няма услуга за печат @msg no revert command няма команда за връщане -@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) няма запазен PDF просет с име „{name}“ (вградените настройки са само за четене: създайте нова от тях) +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) няма запазен PDF просет с име `{name}` (вградените настройки са само за четене: създайте нова от тях) @msg no saved presets няма запазени предварителни настройки -@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) няма запазена предварителна настройка за печат с име „{name}“ ({DEFAULT_PRESET} е защитена: създайте нова от нея) +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) няма запазена предварителна настройка за печат с име `{name}` ({DEFAULT_PRESET} е защитена: създайте нова от нея) @msg no saved profiles няма запазени профили @msg no saved selection recalled няма възстановена запазена селекция @msg no saved selections няма запазени селекции @@ -4207,13 +4207,13 @@ axis Both И двете @msg no such user library няма такава потребителска библиотека @msg no such view няма такъв изглед @msg no such width point няма такава точка за ширина -@msg no swatch `{name}` няма образец „{name}“ -@msg no swatch library `{l}` (see swatch.library.list) няма библиотека с образци „{l}“ (вижте swatch.library.list) +@msg no swatch `{name}` няма образец `{name}` +@msg no swatch library `{l}` (see swatch.library.list) няма библиотека с образци `{l}` (вижте swatch.library.list) @msg no symbol instances selected няма избрани екземпляри на символ @msg no target object has an opacity mask: select one or give `id` никой целеви обект няма маска за непрозрачност: изберете един или задайте `id` @msg no threaded text selected няма избран свързан текст @msg no user library folder here тук няма папка с потребителска библиотека -@msg no user workspace `{from}` няма потребителско работно пространство „{from}“ +@msg no user workspace `{from}` няма потребителско работно пространство `{from}` @msg node {_1} is not text възелът {_1} не е текст @msg not a DXF drawing не е DXF чертеж @msg not a VectorCraft file: {0} не е файл на VectorCraft: {0} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 0f960961e..933f5a329 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2062,6 +2062,47 @@ fn shared_param_check_errors_show_their_values_as_given() { } } +/// The spans of `s` between pairs of backticks, such as a parameter key or a command id; none when +/// its backticks don't pair up. +fn backticked(s: &str) -> Vec<&str> { + let parts: Vec<&str> = s.split('`').collect(); + if parts.len().is_multiple_of(2) { + return Vec::new(); + } + parts.into_iter().skip(1).step_by(2).filter(|t| !t.is_empty()).collect() +} + +#[test] +fn backticked_finds_the_spans_between_paired_backticks() { + assert_eq!(backticked("`ids` must be an array, see `plugin.list`"), ["ids", "plugin.list"]); + assert_eq!(backticked("no backticks"), Vec::<&str>::new()); + assert_eq!(backticked("a lone ` backtick"), Vec::<&str>::new()); +} + +/// A translated row keeps each backticked span of its English text, in backticks and as the +/// English spells it: the span names a parameter key, a command id or a placeholder, which the +/// command reads as written. +#[test] +fn translations_keep_the_backticked_names_of_their_english() { + let mut missing = Vec::new(); + for l in &LANGUAGES { + let (entries, _) = parse_entries(l.source); + for (ctx, src, tr) in &entries { + let plural = ctx == "@plural" || ctx == "@msgplural"; + let english = if plural { src.rsplit('|').next().unwrap_or(src) } else { src.as_str() }; + let forms: Vec<&str> = if plural { tr.split('|').collect() } else { vec![tr.as_str()] }; + for form in forms { + for token in backticked(english) { + if !form.contains(&format!("`{token}`")) { + missing.push(format!("{}: `{token}` in {src:?} -> {form:?}", l.code)); + } + } + } + } + } + assert!(missing.is_empty(), "{} rows drop a backticked name:\n{}", missing.len(), missing.join("\n")); +} + /// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists /// the messages each one lacks. const COMPLETE_MESSAGES: &[&str] = &[ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7d6676c87..20cad5f53 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2815,7 +2815,7 @@ digits Malayalam Malayalam Apply {name} “{name}” Stilini Uygula Redefine {name} “{name}” Stilini Yeniden Tanımla @msg `Custom` stands for changed settings: save the preset under another name `Custom` değiştirilmiş ayarları ifade eder: ön ayarı başka bir adla kaydedin -@msg `{key}` must be an array of these names: {_1} "{key}" şu adlardan oluşan bir dizi olmalıdır: {_1} +@msg `{key}` must be an array of these names: {_1} `{key}` şu adlardan oluşan bir dizi olmalıdır: {_1} Search commands and tools Komutları ve araçları arayın Search commands and tools… Komutları ve araçları arayın… Search Commands… Arama Komutları… @@ -3764,7 +3764,7 @@ axis Both Her İkisi @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same üzerlerindeki görseller sayfanınkinden %{_1} oranında farklıdır: yine de düzenleme verilerinden gelirler @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer dosyanın katmanları okunmadı ({_1}): tek katmanda kendi sayfası olarak geliyor @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part dosyanın katmanları düzenleme verilerinden okunmadı ({_1}): PDF kısmından geliyorlar -@msg `to` must be "corner" or "smooth" "to" "köşe" veya "pürüzsüz" olmalıdır +@msg `to` must be "corner" or "smooth" `to` "corner" veya "smooth" olmalıdır @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` "emBox", "capHeight", "xHeight" veya "icfBox" olmalıdır @msg BMP files are RGB or grayscale: colorModel rgb or gray BMP dosyaları RGB veya gri tonlamalıdır: colorModel rgb veya gri @msg Blend colors: select at least three objects with filled colours Renkleri karıştır: dolgulu renklere sahip en az üç nesne seçin @@ -3836,35 +3836,35 @@ axis Both Her İkisi @msg Targa files are RGB: colorModel rgb Targa dosyaları RGB'dir: colorModel rgb @msg Text Wrap: select a wrap object Metin Sarma: bir sarma nesnesi seçin @msg This shortcut file is in a form VectorCraft can't read. Bu kısayol dosyası VectorCraft'ın okuyamayacağı bir biçimdedir. -@msg `aspect` must be a number (%) "boyut" bir sayı olmalıdır (%) +@msg `aspect` must be a number (%) `aspect` bir sayı olmalıdır (%) @msg `at` must be [x, y] `at` [x, y] olmalıdır -@msg `color` must be a colour "renk" bir renk olmalıdır -@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `komut`, menü öğesi bir iletişim kutusu açan bir komut olmalıdır (bkz. ui.menuDialog) -@msg `freeform.points` must be an array "freeform.points" bir dizi olmalıdır -@msg `freeform.lines` must be an array "freeform.lines" bir dizi olmalıdır -@msg `gradient` must be an object "gradyan" bir nesne olmalıdır +@msg `color` must be a colour `color` bir renk olmalıdır +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command`, menü öğesi bir iletişim kutusu açan bir komut olmalıdır (bkz. ui.menuDialog) +@msg `freeform.points` must be an array `freeform.points` bir dizi olmalıdır +@msg `freeform.lines` must be an array `freeform.lines` bir dizi olmalıdır +@msg `gradient` must be an object `gradient` bir nesne olmalıdır @msg `groupColors` must be an array `groupColors` bir dizi olmalıdır -@msg `library` is a library id or name (see {list}) 'kitaplık' bir kitaplık kimliği veya adıdır (bkz. {list}) -@msg `mode` applies to freeform gradients "mod" serbest biçimli degradeler için geçerlidir +@msg `library` is a library id or name (see {list}) `library` bir kitaplık kimliği veya adıdır (bkz. {list}) +@msg `mode` applies to freeform gradients `mode` serbest biçimli degradeler için geçerlidir @msg `preserve` must be an object `preserve` bir nesne olmalıdır @msg `globalAdjust` must be an object `globalAdjust` bir nesne olmalıdır -@msg `sampleSize` must be 1, 3 or 5 "sampleSize" 1, 3 veya 5 olmalıdır -@msg `stops` must be an array "duraklar" bir dizi olmalıdır +@msg `sampleSize` must be 1, 3 or 5 `sampleSize` 1, 3 veya 5 olmalıdır +@msg `stops` must be an array `stops` bir dizi olmalıdır @msg `svg` must be an object of SVG options (see document.formats) `svg`, SVG seçeneklerinin bir nesnesi olmalıdır (bkz. document.formats) @msg `{_1}` is no save command `{_1}` kaydetme komutu değil @msg `{area}` is no recovery area name `{area}`, kurtarma alanı adı değildir @msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` bir renk değildir (#rrggbb veya önceden ayarlanmış bir ad) @msg `{id}` has no envelope dialog `{id}`de zarf iletişim kutusu yok @msg `{keep}` is not a side of this axis `{keep}` bu eksenin bir tarafı değil -@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` "{key}", "auto" veya "en" veya "zh-hant" gibi bir dil kodu olmalıdır -@msg `{key}` must be a #rrggbb colour "{key}" #rrggbb rengi olmalıdır -@msg `{key}` must be a number "{key}" bir sayı olmalıdır -@msg `{key}` must be a string "{key}" bir dize olmalıdır +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}`, `auto` ya da `en` veya `zh-hant` gibi bir dil kodu olmalıdır +@msg `{key}` must be a #rrggbb colour `{key}` bir #rrggbb rengi olmalıdır +@msg `{key}` must be a number `{key}` bir sayı olmalıdır +@msg `{key}` must be a string `{key}` bir dize olmalıdır @msg `{key}` must be a whole number `{key}` bir tam sayı olmalıdır @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında bir tam sayı olmalıdır ({x}'yi aldı) @msg `{key}` must be between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında olmalıdır ({x}'yi aldı) @msg `{key}` must be one of {_1} `{key}`, {_1}'den biri olmalıdır -@msg `{key}` must be true or false "{key}" doğru veya yanlış olmalıdır +@msg `{key}` must be true or false `{key}` true veya false olmalıdır @msg `top` and `below` can't both be true `top` ve `below` aynı anda true olamaz @msg `select` must be true or false `select` true veya false olmalıdır @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` ve `maxAnchors` tam sayı olmalıdır @@ -3872,8 +3872,8 @@ axis Both Her İkisi @msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` [x, y, width, height] olmalıdır: dört sonlu sayı, genişlik ve yükseklik negatif olmamalı @msg `{min_key}` is greater than `{max_key}` `{min_key}`, `{max_key}` değerinden büyük @msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` şunlardan en az birini verin: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` -@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe "mod" gri tonlamalı, rgb, hsb, cmyk veya web uyumlu olmalıdır -@msg `{k}` must be [x, y] "{k}" [x, y] olmalıdır +@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` grayscale, rgb, hsb, cmyk veya websafe olmalıdır +@msg `{k}` must be [x, y] `{k}` [x, y] olmalıdır @msg `{name}` has no options `{name}`nin seçeneği yok @msg `{name}` is a built-in workspace; choose another name `{name}` yerleşik bir çalışma alanıdır; başka bir isim seç @msg `{name}` is a process colour: tints apply to global and spot colours `{name}` bir proses renktir: renk tonları global ve spot renklere uygulanır @@ -3920,7 +3920,7 @@ axis Both Her İkisi @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: yok, resim veya tür @msg artboard {0} does not exist {0} çalışma yüzeyi mevcut değil @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard bu ölçekte çizim {w} × {h} nk, kanvasın sığabileceğinden daha fazladır: daha küçük bir ölçek seçin veya çalışma yüzeyine sığdırın -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` eksen "{v}" değil, "dikey", "yatay" veya derece cinsinden bir açı olmalıdır +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis "vertical", "horizontal" veya derece cinsinden bir açı olmalıdır, `{v}` değil @msg background {other}: transparent, white, black or a colour such as "#ff8800" arka plan {other}: şeffaf, beyaz, siyah veya "#ff8800" gibi bir renk @msg bad color {v} kötü renk {v} @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio 0,1 ile 10 arasında bir sayı olmalıdır @@ -3950,7 +3950,7 @@ axis Both Her İkisi @msg check an artboard to export dışa aktarılacak bir çalışma yüzeyi işaretleyin @msg check a layer to export dışa aktarılacak katmanı kontrol edin @msg select the art to export first önce dışa aktarılacak resmi seçin -@msg choice must be "merge" or "add", not `{choice}` seçim "{choice}" değil "birleştir" veya "ekle" olmalıdır +@msg choice must be "merge" or "add", not `{choice}` choice "merge" veya "add" olmalıdır, `{choice}` değil @msg choose a direction to divide in bölmek için bir yön seçin @msg choose a folder to export to dışa aktarılacak bir klasör seçin @msg choose a location for the package paket için bir yer seçin @@ -3963,14 +3963,14 @@ axis Both Her İkisi @msg damaged hasarlı @msg decimals must be {_1}–{_2} (got {_3}) ondalık sayılar {_1}–{_2} olmalıdır ({_3}'yi aldı) @msg divide into 1 to {MAX_PIECES} slices 1 ila {MAX_PIECES} dilimlerine bölün -@msg effect {k} is `{_1}`, not `{id}` {k} efekti "{id}" değil "{_1}"dir +@msg effect {k} is `{_1}`, not `{id}` {k} efekti `{_1}`, `{id}` değil @msg empty brush stroke boş fırça darbesi @msg empty cutter boş kesici @msg empty path boş yol @msg file version {0} is newer than this VectorCraft supports ({VERSION}) dosya sürümü {0}, VectorCraft'ın desteklediğinden daha yenidir ({VERSION}) -@msg freeform point {i} needs `at` [x, y] and a valid `color` serbest biçimli nokta {i}'nin "[x, y]'de" ve geçerli bir "renk"te olması gerekir +@msg freeform point {i} needs `at` [x, y] and a valid `color` serbest biçimli nokta {i} için `at` [x, y] ve geçerli bir `color` gerekir @msg freeform line {i} must be an array of point indices Serbest biçimli çizgi {i}, bir nokta indeks dizisi olmalıdır -@msg give both `start` and `end` [x, y] (or neither) hem "başlangıç" hem de "son" [x, y] verin (ya da ikisini de) +@msg give both `start` and `end` [x, y] (or neither) hem `start` hem de `end` [x, y] verin (ya da hiçbirini) @msg give corners of the path: anchors where its sides meet at an angle yolun köşelerini verin: kenarlarının belli bir açıyla buluştuğu noktalara sabitleyin @msg give horizontal or vertical yatay veya dikey ver @msg give the path `id` and the width point `index` yol `id`sini ve genişlik noktası `index`i verin @@ -3978,8 +3978,8 @@ axis Both Her İkisi @msg image '{key}' has no pixels and was left out '{key}' resminde piksel yok ve dışarıda bırakıldı @msg image data `{_1}` is missing `{_1}` resim verisi eksik @msg image would be too large; lower the resolution resim çok büyük olur; çözünürlüğü düşür -@msg index must be a number or first, previous, next or last dizin bir sayı veya ilk, önceki, sonraki veya son olmalıdır -@msg index must be a number or first, previous, next or last, not `{other}` dizin bir sayı veya ilk, önceki, sonraki veya son olmalıdır, "{other}" değil +@msg index must be a number or first, previous, next or last index bir sayı ya da first, previous, next veya last olmalıdır +@msg index must be a number or first, previous, next or last, not `{other}` index bir sayı ya da first, previous, next veya last olmalıdır, `{other}` değil @msg installing from a path is not available on the web; pass the module as `dataBase64` bir yoldan kurulum web'de mümkün değildir; modülü `dataBase64` olarak iletin @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}`de dahili hata: {msg} (belge daha önce olduğu gibi bırakıldı; lütfen bu hatayı bildirin) @msg internal error: {msg} (please report this bug) dahili hata: {msg} (lütfen bu hatayı bildirin) @@ -3996,10 +3996,10 @@ axis Both Her İkisi @msg layers can't be wrap objects katmanlar sarma nesneleri olamaz @msg line {line}: value for `{var}` must be true or false satır {line}: `{var}` değeri true veya false olmalıdır @msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes satır {line}: `{var}` değeri {MAX_GRAPH_CSV} bayttan uzun -@msg missing `folder` 'klasör' eksik -@msg missing `id` (see plugin.list) `kimlik` eksik (bkz. eklenti.liste) -@msg missing `name` "isim" eksik -@msg missing `tool` 'araç' eksik +@msg missing `folder` `folder` eksik +@msg missing `id` (see plugin.list) `id` eksik (bkz. plugin.list) +@msg missing `name` `name` eksik +@msg missing `tool` `tool` eksik @msg missing id id eksik @msg missing index eksik dizin @msg New Window isn't available yet: a document opens in one window Yeni Pencere henüz kullanılamıyor: bir belge tek pencerede açılır @@ -4052,7 +4052,7 @@ axis Both Her İkisi @msg no library `{key}` (see {list}) `{key}` kitaplığı yok (bkz. {list}) @msg no native format yerel format yok @msg no pages sayfa yok -@msg no pattern `{name}` "{name}" modeli yok +@msg no pattern `{name}` `{name}` deseni yok @msg no perspective grid preset named `{name}` `{name}` adında perspektif ızgarası ön ayarı yok @msg no plug-in {0} is installed {0} eklentisi kurulu değil @msg no plug-in {id} is installed {id} eklentisi kurulu değil @@ -4103,7 +4103,7 @@ axis Both Her İkisi @msg no swatch `{name}` renk örneği yok `{name}` @msg no swatch library `{l}` (see swatch.library.list) renk örneği kitaplığı yok `{l}` (bkz. swatch.library.list) @msg no symbol instances selected sembol örneği seçilmedi -@msg no target object has an opacity mask: select one or give `id` hiçbir hedef nesnenin opaklık maskesi yok: birini seçin veya "id" verin +@msg no target object has an opacity mask: select one or give `id` hiçbir hedef nesnenin opaklık maskesi yok: birini seçin veya `id` verin @msg no threaded text selected dizili metin seçilmedi @msg no user library folder here burada kullanıcı kitaplığı klasörü yok @msg no user workspace `{from}` kullanıcı çalışma alanı yok `{from}` @@ -4111,7 +4111,7 @@ axis Both Her İkisi @msg node {_1} is on a locked artboard {_1} düğümü kilitli bir çalışma yüzeyinde @msg not a DXF drawing DXF çizimi değil @msg not a VectorCraft file: {0} bir VectorCraft dosyası değil: {0} -@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft kısayol seti değil ("geçersiz kılmalar" eksik) +@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft kısayol seti değil (`overrides` eksik) @msg not a graphic style library (format `{_1}`) grafik stili kitaplığı değil (`{_1}` biçimi) @msg not a shortcut set: the text doesn't start with a slash name and an opening brace bir kısayol seti değil: metin eğik çizgi adı ve açılış paranteziyle başlamıyor @msg not a stored central record saklanan merkezi bir kayıt değil @@ -4193,7 +4193,7 @@ axis Both Her İkisi @msg select at least two objects en az iki nesne seç @msg exit isolation mode first önce izolasyon modundan çık @msg select objects, not layers katmanları değil nesneleri seçin -@msg select one path, or give its `id` bir yol seçin veya "kimliğini" verin +@msg select one path, or give its `id` bir yol seçin veya `id` değerini verin @msg select paths to join katılmak için yolları seçin @msg select the art to repeat tekrarlanacak resmi seçin @msg select the artwork to warp çarpıtılacak resmi seçin @@ -4204,10 +4204,10 @@ axis Both Her İkisi @msg shadings of an unknown type were left out bilinmeyen türdeki gölgeler dışarıda bırakıldı @msg shadings of more than 4096 patches or triangles were left out 4096'dan fazla yamanın veya üçgenin gölgeleri dışarıda bırakıldı @msg shadings whose functions are of an unknown type are filled with their middle colour İşlevi bilinmeyen tipteki gölgelikler orta renkle doldurulur -@msg shortcut for `{k}` must be a string "{k}" kısayolu bir dize olmalıdır -@msg stop {i} needs `offset` {i}'nin `ofset`e ihtiyacı var -@msg stop {i} needs a valid `color` stop {i}'nin geçerli bir "renk"e ihtiyacı var -@msg stop {i}: `{k}` must be a number {i}'yi durdur: `{k}` bir sayı olmalıdır +@msg shortcut for `{k}` must be a string `{k}` kısayolu bir dize olmalıdır +@msg stop {i} needs `offset` durak {i} için `offset` gerekir +@msg stop {i} needs a valid `color` durak {i} için geçerli bir `color` gerekir +@msg stop {i}: `{k}` must be a number durak {i}: `{k}` bir sayı olmalıdır @msg the EMF header is damaged EMF başlığı hasarlı @msg the PDF has no pages PDF'de sayfa yok @msg the PDF is no longer loaded PDF artık yüklenmiyor @@ -4286,7 +4286,7 @@ axis Both Her İkisi @msg unknown freeform mode `{m}` (points, lines) bilinmeyen serbest biçimli mod `{m}` (noktalar, çizgiler) @msg unknown gradient kind `{k}` (linear, radial, freeform) bilinmeyen gradyan türü `{k}` (doğrusal, radyal, serbest biçimli) @msg unknown highlight `{h}` bilinmeyen vurgu `{h}` -@msg unknown option `{path}.{k}` bilinmeyen seçenek '{path}.{k}' +@msg unknown option `{path}.{k}` bilinmeyen seçenek `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (koru, benzet veya at) @msg unknown panel `{raw}` bilinmeyen panel `{raw}` @msg unknown preference `{key}` bilinmeyen tercih `{key}` @@ -4486,9 +4486,9 @@ axis Both Her İkisi Em Box Em Kutusu x-Height x-Yüksekliği ICF Box ICF Kutusu -@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve "id", yalnızca "run" ve "command_batch" çözümleyen {r} adım referansıdır -@msg `ids` must be an array of object ids, not {v} "kimlikler" {v} değil, bir nesne kimlikleri dizisi olmalıdır -@msg `corners` must be an array of anchor indices, not {v} "köşeler" {v} değil, bir bağlantı indeksleri dizisi olmalıdır +@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`, yalnızca `run` ve `command_batch` komutlarının çözümlediği {r} adım referansıdır +@msg `ids` must be an array of object ids, not {v} `ids` bir nesne kimlikleri dizisi olmalıdır, {v} değil +@msg `corners` must be an array of anchor indices, not {v} `corners` bir bağlantı noktası indeksleri dizisi olmalıdır, {v} değil @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: {i} adımının sonucunun böyle bir değeri yok (değişmez $ için $$ yazın) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: bundan önce {i} adımı yok (adımlar 0'dan itibaren sayılır; $$ yerine $ yazın) @msg object ids are non-negative integers, not {v} nesne kimlikleri {v} değil, negatif olmayan tam sayılardır @@ -5241,10 +5241,10 @@ theme Appearance Görünüm @msg Unknown unit Bilinmeyen birim Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Açık profillerin dairesel uçlarını kapatın. Kısmi süpürmeler kesik taraflarını açık tutar. @msg span must be "object" or "selection", got {v} yayılma "nesne" veya "seçim" olmalıdır, {v} var -@msg span: "selection" places a `gradient`, not a swatch yayılma: "seçim" bir renk örneği değil, bir "degrade" yerleştirir -@msg span: "selection" places a gradient: give `gradient` span: "selection" bir degrade yerleştirir: "gradient" verir -@msg span: "selection" places a linear or radial gradient, not a freeform one yayılma: "seçim" serbest biçimli bir degrade değil, doğrusal veya radyal bir degrade yerleştirir -@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" degradeyi kendisi yerleştirir: "start" ve "end"i dışarıda bırakın (bunu zaten belge koordinatlarına yerleştirdiler) +@msg span: "selection" places a `gradient`, not a swatch span: "selection" bir renk örneği değil, bir `gradient` yerleştirir +@msg span: "selection" places a gradient: give `gradient` span: "selection" bir degrade yerleştirir: `gradient` verin +@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" serbest biçimli bir degrade değil, doğrusal veya radyal bir degrade yerleştirir +@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" degradenin kendisini yerleştirir: `start` ve `end` değerlerini vermeyin (bunlar degradeyi zaten belge koordinatlarına yerleştirir) Make Image Dynamic Görüntüyü Dinamik Hale Getirin Import Data Sets… Veri Kümelerini İçe Aktar… Export Data Sets… Veri Kümelerini Dışa Aktarma… @@ -5726,7 +5726,7 @@ axis Y Axis Y ekseni Fine noise hides banding in the gradient fill İnce gürültü, degrade dolgudaki bantları gizler @msg `dither` applies to gradient fills (stroke: false) `dither` degrade dolgulara uygulanır (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` degrade dolgulara uygulanır (paint.setFill) -@msg `dither` must be true or false "dither" doğru veya yanlış olmalıdır +@msg `dither` must be true or false `dither` true veya false olmalıdır Snapping Yaslama Snap to Tangent Teğete Yasla Snap to Perpendicular Dikeye Yasla From 500f74b7008915cb073bfd86c3c100d8fb5ab2cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 22:30:03 -0800 Subject: [PATCH 49/52] Tests: the index read count includes entries read from more than one cell RectIndex::at notes its reads before it sorts and removes repeats, so an entry filed under several of the cells a lookup spans counts once per cell. --- crates/pathops/src/planar/index.rs | 4 ++-- crates/pathops/src/planar/tests.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/pathops/src/planar/index.rs b/crates/pathops/src/planar/index.rs index 7d709e80d..3c7d9f9be 100644 --- a/crates/pathops/src/planar/index.rs +++ b/crates/pathops/src/planar/index.rs @@ -219,10 +219,10 @@ impl RectIndex { } } } - found.sort_unstable(); - found.dedup(); #[cfg(test)] note_work(found.len()); + found.sort_unstable(); + found.dedup(); found.into_iter().filter_map(move |i| self.items.get(i)).filter(move |(r, _)| r.inflate(tol, tol).contains(p)).map(|(_, t)| t) } } diff --git a/crates/pathops/src/planar/tests.rs b/crates/pathops/src/planar/tests.rs index 025d760f4..f9b88a27f 100644 --- a/crates/pathops/src/planar/tests.rs +++ b/crates/pathops/src/planar/tests.rs @@ -738,6 +738,19 @@ fn rect_index_reads_a_diagonal_hatch_only_near_the_point() { assert_eq!(index.at(Point::new(-5.0, 0.0), 0.1).count(), 0); } +#[test] +fn rect_index_counts_each_entry_it_reads() { + // Ten collinear lines filed under each of the 10 cells along them: a lookup 300 pt either side + // reads every line from each of the cells it spans, more than once per line. + let line = PathSeg::Line(kurbo::Line::new((0.0, 0.0), (1000.0, 0.0))); + let items: Vec<_> = (0..10).map(|k| (line.bounding_box(), (line, k))).collect(); + let index = RectIndex::with_footprint(items, |r, (s, _), cell| segment_footprint(r, s, cell)); + let started = work_done(); + assert_eq!(index.at(Point::new(500.0, 0.0), 300.0).count(), 10); + let work = work_done() - started; + assert!(work >= 50, "{work} reads"); +} + /// The grid's memory stays linear in the item count: pieces of one segment falling in the same /// cell file it once, and coincident segments (each along every cell of a one-column grid) go /// to the scanned list once the grid holds its budget of references, and are still all found. From a8a1c82a434f4ecbb94461fed60ce26386f58fcb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 21:55:52 -0800 Subject: [PATCH 50/52] PDF export: warn when a PDF/X file leaves out the document's bleed Choosing a PDF/X standard without a PDF/X preset writes a file with no bleed (BleedBox = TrimBox) even when Document Setup sets one, and the export gave no warning about it. The export now warns when a PDF/X file has no bleed on a side where the document's bleed has one. The warning comes ahead of the flattening note, because the status bar shows only the first warning. The code adds a warning and does not change what is written. These cases do not warn: the PDF/X presets, Use document bleed settings, a nonzero bleed on every side the document sets, a document with no bleed, files that aren't PDF/X files, and Export for Screens rows whose artboards Include Bleed grows. The warning has a row in every catalog. The Summary's warning and error rows in the Save PDF and Print dialogs now go through the catalog, so they show its translations. --- crates/engine/src/cmd/fileio/pdf.rs | 12 ++++- crates/engine/src/cmd/fileio/screens.rs | 5 +- crates/engine/src/cmd/fileio/tests_pdfx.rs | 49 +++++++++++++++++++ crates/engine/src/cmd/fileio/tests_screens.rs | 14 ++++++ crates/pdf/src/marks.rs | 9 +++- crates/pdf/src/tests/marks.rs | 16 ++++++ crates/ui-egui/src/dialogs/print.rs | 2 +- crates/ui-egui/src/dialogs/save_pdf.rs | 9 +++- crates/ui-egui/src/dialogs/save_pdf/tests.rs | 11 +++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tests.rs | 14 ++++++ crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 39 files changed, 165 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 812b8ac97..785764db9 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -56,7 +56,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ name: "bleed", ty: "object", default: "null", - description: "{useDocument, top, bottom, left, right} in points: the page grows by the bleed (BleedBox) around its artboard (TrimBox)", + description: "{useDocument, top, bottom, left, right} in points: the page grows by the bleed (BleedBox) around its artboard (TrimBox); a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning", }, FormatOption { name: "output", @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", has_doc, export_pdf ), @@ -257,6 +257,10 @@ pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result { pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = "Preserve editing was left out: it needs a PDF of every artboard, in order (reopened, this one would show the others too)"; +/// The warning for a PDF/X file that leaves out the document's bleed +/// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). +pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; + /// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). @@ -299,6 +303,10 @@ pub(super) fn encode_carrying( opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?; } let mut warnings = vec![]; + // The status bar shows the first warning, so this one goes ahead of the flattening note. + if opts.settings.leaves_out_bleed(doc) { + warnings.push(BLEED_LEFT_OUT.to_string()); + } if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { opts.native = Some(native()?); diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 26737e158..201a4b479 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -1019,11 +1019,14 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> let mut out = Output::default(); // The encoders' warnings for every file, each once. let mut warnings: Vec = Vec::new(); + // Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are + // written on a page of their own bounds, which it doesn't grow. + let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none(); let mut write = |doc: &Document, format: &str, options: &Value| -> Result> { // The steps are the files: an encoder's own steps (artboards, pages) are not reported. let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?; for w in notes { - if !warnings.contains(&w) { + if !(bleed_on_page && w == super::pdf::BLEED_LEFT_OUT) && !warnings.contains(&w) { warnings.push(w); } } diff --git a/crates/engine/src/cmd/fileio/tests_pdfx.rs b/crates/engine/src/cmd/fileio/tests_pdfx.rs index f8c3e0c59..abd1bbe4a 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfx.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfx.rs @@ -90,6 +90,55 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() { } } +/// A PDF/X file without the bleed the document sets exports as asked and warns; the presets and +/// options that take the document's bleed, and files that aren't PDF/X files, don't warn. +#[test] +fn a_pdfx_file_that_leaves_out_the_document_bleed_warns() { + let left_out = |w: &Vec| w.iter().any(|w| w == super::pdf::BLEED_LEFT_OUT); + let mut s = session(); + s.execute("document.setup", &json!({"bleed": 6})).unwrap(); + for standard in ["pdfX1a", "pdfX3", "pdfX4"] { + let (_, warnings) = pdf(&mut s, json!({"standard": standard})); + // First: the status bar shows the first warning, and PDF/X-1a and PDF/X-3 add the flattening note. + assert_eq!(warnings.first().map(String::as_str), Some(super::pdf::BLEED_LEFT_OUT), "{standard}: {warnings:?}"); + // The page has no bleed, as asked. + let v = s.execute("document.exportPdf", &json!({"standard": standard})).unwrap(); + let info = s.execute("document.pdfInfo", &json!({"name": "x.pdf", "dataBase64": v["dataBase64"]})).unwrap(); + let boxes = &info["pageInfo"][0]["boxes"]; + assert!(boxes["media"].is_array() && boxes["media"] == boxes["trim"] && boxes["media"] == boxes["bleed"], "{standard}: {info}"); + } + let v = s.execute("document.export", &json!({"format": "pdf", "standard": "pdfX4"})).unwrap(); + assert!(v["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::BLEED_LEFT_OUT), "{v}"); + // A side the settings leave out warns, and sides they all keep don't. + let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4", "bleed": {"top": 6, "bottom": 6, "left": 6}})); + assert!(left_out(&warnings), "{warnings:?}"); + for p in [ + json!({"preset": "PDF/X-1a:2001"}), + json!({"preset": "PDF/X-3:2002"}), + json!({"preset": "PDF/X-4:2010"}), + json!({"standard": "pdfX4", "bleed": {"useDocument": true}}), + json!({"standard": "pdfX4", "bleed": {"top": 1, "bottom": 1, "left": 1, "right": 1}}), + json!({}), + json!({"standard": "pdfA2b"}), + ] { + let (_, warnings) = pdf(&mut s, p.clone()); + assert!(!left_out(&warnings), "{p}: {warnings:?}"); + } + // The dialog's Summary exports in memory, so it lists the warning too. + let v = s.execute("document.pdfSettings", &json!({"standard": "pdfX4", "includeDocument": true})).unwrap(); + assert!(v["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::BLEED_LEFT_OUT), "{v}"); + // A bleed on the top alone: only that side counts. + s.execute("document.setup", &json!({"bleed": [6, 0, 0, 0]})).unwrap(); + let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4", "bleed": {"top": 1}})); + assert!(!left_out(&warnings), "{warnings:?}"); + let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4", "bleed": {"bottom": 1}})); + assert!(left_out(&warnings), "{warnings:?}"); + // A document without a bleed has nothing to leave out. + s.execute("document.setup", &json!({"bleed": 0})).unwrap(); + let (_, warnings) = pdf(&mut s, json!({"standard": "pdfX4"})); + assert!(!left_out(&warnings), "{warnings:?}"); +} + /// The ink amounts (0..1) `doc` shows at `p` in its images (the topmost image there). fn image_inks_at(doc: &vectorcraft_doc::Document, p: vectorcraft_geom::Point) -> Option<[f32; 4]> { fn visit(n: &vectorcraft_doc::Node, out: &mut Vec) { diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index f1790225a..64dd14dcf 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -526,6 +526,20 @@ fn include_bleed_grows_each_artboard() { assert_eq!(size(&files(&r)[0].1), (40, 30), "without it, the artboard alone"); } +/// Include Bleed puts the bleed on a PDF/X row's page, so the row doesn't warn that it leaves the bleed out. +#[test] +fn include_bleed_pdfx_rows_do_not_warn_about_the_bleed() { + let mut s = session(1); + s.execute("document.setup", &json!({"bleed": 6})).unwrap(); + let warned = |r: &Value| r["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::BLEED_LEFT_OUT); + let row = json!([{"format": "pdf", "standard": "pdfX4"}]); + assert!(warned(&export(&mut s, json!({"formats": row})))); + assert!(!warned(&export(&mut s, json!({"includeBleed": true, "formats": row})))); + // A selection is written on a page of its own bounds, which Include Bleed doesn't grow. + s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); + assert!(warned(&export(&mut s, json!({"includeBleed": true, "selection": "one", "formats": row})))); +} + #[test] fn png_8_rows_write_indexed_colour() { let mut s = session(1); diff --git a/crates/pdf/src/marks.rs b/crates/pdf/src/marks.rs index dc4575f26..37fc9f6c8 100644 --- a/crates/pdf/src/marks.rs +++ b/crates/pdf/src/marks.rs @@ -4,7 +4,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::marks::{MarkStyle, PrinterMarks, outset}; -use vectorcraft_doc::setup::MAX_BLEED; +use vectorcraft_doc::setup::{MAX_BLEED, bleed_in_range}; use vectorcraft_doc::{Document, Node, NodeId}; use vectorcraft_geom::Rect; @@ -35,6 +35,13 @@ impl PdfSettings { pub fn bleed_of(&self, doc: &Document) -> [f64; 4] { self.bleed.of(doc) } + + /// A PDF/X file of `doc` has no bleed on a side where the document's bleed (Document Setup) has + /// one. + pub fn leaves_out_bleed(&self, doc: &Document) -> bool { + self.standard.is_pdfx() + && self.bleed_of(doc).into_iter().zip(bleed_in_range(doc.setup.bleed)).any(|(page, document)| page <= 0.0 && document > 0.0) + } } impl BleedSettings { diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 0c682b8d5..e4df914a1 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -82,6 +82,22 @@ fn bleed_grows_the_media_box_around_the_trim_box() { assert_eq!(page_box(&bytes, CropTo::Trim), Rect::new(3.0, 2.0, 203.0, 102.0)); } +#[test] +fn a_pdfx_file_leaves_out_the_document_bleed_side_by_side() { + let x4 = |bleed: serde_json::Value| settings(json!({"standard": "pdfX4", "bleed": bleed})); + let mut d = doc(); + d.setup.bleed = [6.0, 0.0, 0.0, 0.0]; + assert!(x4(json!({})).leaves_out_bleed(&d)); + assert!(x4(json!({"bottom": 1})).leaves_out_bleed(&d), "the top is left out"); + assert!(!x4(json!({"top": 1})).leaves_out_bleed(&d), "the sides without a document bleed don't count"); + assert!(!x4(json!({"useDocument": true})).leaves_out_bleed(&d)); + assert!(!settings(json!({})).leaves_out_bleed(&d), "not a PDF/X file"); + // A document bleed read from a file counts as it is kept in range. + d.setup.bleed = [f64::INFINITY, f64::NAN, -3.0, 0.0]; + assert!(!x4(json!({})).leaves_out_bleed(&d)); + assert!(!x4(json!({"useDocument": true})).leaves_out_bleed(&d)); +} + /// Each page's `UserUnit` and media box as the file holds them (in its units). fn units_and_media(bytes: &[u8]) -> Vec<(f32, Rect)> { let file = crate::pages::open(bytes, None).unwrap(); diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 76650401c..6b69c9b3f 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -851,7 +851,7 @@ fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result, has_do Err(_) if !has_doc => {} Err(e) => { heading(ui, tl!("Error")); - ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text)); + ui.label(egui::RichText::new(super::save_pdf::warning_line(crate::i18n::current(), e)).color(t.text)); } } heading(ui, tl!("Options")); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 72a8d64ac..ee68105a6 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -792,7 +792,7 @@ fn summary(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { Ok(v) => v, Err(e) => { heading(ui, tl!("Error")); - ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text)); + ui.label(egui::RichText::new(warning_line(crate::i18n::current(), &e)).color(t.text)); return; } }; @@ -829,9 +829,14 @@ pub(super) fn warning_rows(ui: &mut egui::Ui, warnings: &[Value]) { note(ui, tl!("None.")); } for w in warnings { - ui.label(egui::RichText::new(format!("⚠ {}", w.as_str().unwrap_or_default())).color(t.text)); + ui.label(egui::RichText::new(warning_line(crate::i18n::current(), w.as_str().unwrap_or_default())).color(t.text)); } } +/// One of the Summary's warnings or errors, in `lang` as far as its catalog has it. +pub(super) fn warning_line(lang: crate::i18n::Lang, w: &str) -> String { + format!("⚠ {}", crate::i18n::message(lang, w)) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index 9f20acd62..8c13a9b61 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -126,6 +126,17 @@ fn warnings_reach_the_status_and_errors_keep_the_dialog_open() { assert!(r["warnings"].is_array()); } +/// The Summary's warnings are translated as far as the catalog has them: a warning without a row +/// keeps its words, and a colon in it can still take the catalog's punctuation. +#[test] +fn summary_warnings_are_translated() { + let fr = crate::i18n::Lang::from_code("fr").unwrap(); + let shown = warning_line(fr, vectorcraft_engine::cmd::fileio::pdf::BLEED_LEFT_OUT); + assert!(shown.starts_with("⚠ ") && !shown.contains("leaves out"), "{shown}"); + assert_eq!(warning_line(fr, "no row for this note"), "⚠ no row for this note"); + assert_eq!(warning_line(fr, "no row: for this note"), "⚠ no row\u{a0}: for this note"); +} + /// A placed image whose pixel data can't be decoded is left out of the PDF, and the status bar /// shows the note raster exports give. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index fdfd7c713..e6914ada9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4475,6 +4475,7 @@ axis Both И двете @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} изображение не може да бъде декодирано: показва се като зачеркната рамка|{n} изображения не могат да бъдат декодирани: показват се като зачеркнати рамки @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} свързано изображение е променено на диска: „Обновяване на връзките“ показва новата версия|{n} свързани изображения са променени на диска: „Обновяване на връзките“ показва новите версии @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Обновен е {n} свързан файл, променен на диска|Обновени са {n} свързани файла, променени на диска +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings документът задава изтичане, което този PDF/X файл пропуска: включете „Използване на настройките за изтичане на документа“ @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} е експортиран с {n} бележка: {first}|{what} е експортиран с {n} бележки: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} шрифтов файл вече е в {folder}|{n} шрифтови файла вече са в {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} шрифтов файл вече е в {folder}; {failed} не можаха да бъдат добавени: {reason}|{n} шрифтови файла вече са в {folder}; {failed} не можаха да бъдат добавени: {reason} diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a90a73f64..9bdeb8492 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4881,6 +4881,7 @@ imageTrace Outline Obris @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} sliku nije moguće dekodirati: prikazuje se kao prekriženi okvir|{n} slike nije moguće dekodirati: prikazuju se kao prekriženi okviri|{n} slika nije moguće dekodirati: prikazuju se kao prekriženi okviri @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} povezana slika promijenjena je na disku: Ažuriraj veze prikazuje novu verziju|{n} povezane slike promijenjene su na disku: Ažuriraj veze prikazuje nove verzije|{n} povezanih slika promijenjeno je na disku: Ažuriraj veze prikazuje nove verzije @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Ažurirana je {n} povezana datoteka promijenjena na disku|Ažurirane su {n} povezane datoteke promijenjene na disku|Ažurirano je {n} povezanih datoteka promijenjenih na disku +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokument zadaje napust koji ova PDF/X datoteka izostavlja: uključite „Koristi postavke napusta dokumenta“ @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Izvezeno {what} s {n} bilješkom: {first}|Izvezeno {what} s {n} bilješke: {first}|Izvezeno {what} s {n} bilješki: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} datoteka fonta sada je u {folder}|{n} datoteke fontova sada su u {folder}|{n} datoteka fontova sada je u {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} datoteka fonta sada je u {folder}; {failed} nije moguće dodati: {reason}|{n} datoteke fontova sada su u {folder}; {failed} nije moguće dodati: {reason}|{n} datoteka fontova sada je u {folder}; {failed} nije moguće dodati: {reason} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 42befdbc1..f28e1c8fd 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5474,6 +5474,7 @@ digits Malayalam Malaiàlam @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes no s’ha pogut descodificar {n} imatge i es mostra com a caixa creuada|no s’han pogut descodificar {n} imatges i es mostren com a caixes creuades @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} imatge enllaçada ha canviat al disc: Actualitza els enllaços mostra la versió nova|{n} imatges enllaçades han canviat al disc: Actualitza els enllaços mostra les versions noves @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk S’ha actualitzat {n} fitxer enllaçat que ha canviat al disc|S’han actualitzat {n} fitxers enllaçats que han canviat al disc +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings el document defineix un sangrat que aquest fitxer PDF/X omet: activeu «Utilitza la configuració de sangrat del document» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} S’ha exportat {what} amb {n} nota: {first}|S’ha exportat {what} amb {n} notes: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} fitxer de tipus de lletra ja és a {folder}|{n} fitxers de tipus de lletra ja són a {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} fitxer de tipus de lletra ja és a {folder}; {failed} no s’han pogut afegir: {reason}|{n} fitxers de tipus de lletra ja són a {folder}; {failed} no s’han pogut afegir: {reason} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4fcc1bcf4..18dba1eec 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4930,6 +4930,7 @@ imageTrace Outline Obrys @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} obrázek nelze dekódovat: zobrazuje se jako přeškrtnutý rámeček|{n} obrázky nelze dekódovat: zobrazují se jako přeškrtnuté rámečky|{n} obrázků nelze dekódovat: zobrazují se jako přeškrtnuté rámečky @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions Na disku se změnil {n} propojený obrázek: novou verzi zobrazí Aktualizovat propojení|Na disku se změnily {n} propojené obrázky: nové verze zobrazí Aktualizovat propojení|Na disku se změnilo {n} propojených obrázků: nové verze zobrazí Aktualizovat propojení @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Aktualizován {n} propojený soubor změněný na disku|Aktualizovány {n} propojené soubory změněné na disku|Aktualizováno {n} propojených souborů změněných na disku +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokument nastavuje spadávku, kterou tento soubor PDF/X vynechává: zapněte „Použít nastavení spadávky dokumentu“ @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Exportováno: {what}, {n} poznámka: {first}|Exportováno: {what}, {n} poznámky: {first}|Exportováno: {what}, {n} poznámek: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} soubor písma je nyní ve složce {folder}|{n} soubory písem jsou nyní ve složce {folder}|{n} souborů písem je nyní ve složce {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} soubor písma je nyní ve složce {folder}; nepodařilo se přidat {failed}: {reason}|{n} soubory písem jsou nyní ve složce {folder}; nepodařilo se přidat {failed}: {reason}|{n} souborů písem je nyní ve složce {folder}; nepodařilo se přidat {failed}: {reason} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 43da801a7..c506c7106 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4562,6 +4562,7 @@ imageTrace Outline Kontur @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} billede kunne ikke afkodes og vises som en overkrydset boks|{n} billeder kunne ikke afkodes og vises som overkrydsede bokse @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} linket billede er ændret på disken: Opdatér links viser den nye version|{n} linkede billeder er ændret på disken: Opdatér links viser de nye versioner @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Opdaterede {n} linket fil ændret på disken|Opdaterede {n} linkede filer ændret på disken +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokumentet angiver et udløb, som denne PDF/X-fil udelader: slå „Brug dokumentets udløbsindstillinger“ til @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Eksporterede {what} med {n} note: {first}|Eksporterede {what} med {n} noter: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} skrifttypefil er nu i {folder}|{n} skrifttypefiler er nu i {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} skrifttypefil er nu i {folder}; {failed} kunne ikke tilføjes: {reason}|{n} skrifttypefiler er nu i {folder}; {failed} kunne ikke tilføjes: {reason} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0d3fcc9e..b2588ed89 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4540,6 +4540,7 @@ theme Appearance Erscheinungsbild @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} Bild konnte nicht dekodiert werden und wird als durchgekreuzter Rahmen angezeigt|{n} Bilder konnten nicht dekodiert werden und werden als durchgekreuzte Rahmen angezeigt @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} verknüpftes Bild wurde auf dem Datenträger geändert: „Verknüpfungen aktualisieren“ zeigt die neue Version|{n} verknüpfte Bilder wurden auf dem Datenträger geändert: „Verknüpfungen aktualisieren“ zeigt die neuen Versionen @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk {n} auf dem Datenträger geänderte verknüpfte Datei aktualisiert|{n} auf dem Datenträger geänderte verknüpfte Dateien aktualisiert +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings das Dokument legt einen Anschnitt fest, den diese PDF/X-Datei auslässt: „Anschnitteinstellungen des Dokuments verwenden“ aktivieren @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} mit {n} Hinweis exportiert: {first}|{what} mit {n} Hinweisen exportiert: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} Schriftdatei liegt jetzt in {folder}|{n} Schriftdateien liegen jetzt in {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} Schriftdatei liegt jetzt in {folder}; {failed} konnten nicht hinzugefügt werden: {reason}|{n} Schriftdateien liegen jetzt in {folder}; {failed} konnten nicht hinzugefügt werden: {reason} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7c290163b..497682bf9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4573,6 +4573,7 @@ digits Malayalam Μαλαγιάλαμ @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} εικόνα δεν ήταν δυνατό να αποκωδικοποιηθεί: εμφανίζεται ως πλαίσιο με σταυρό|{n} εικόνες δεν ήταν δυνατό να αποκωδικοποιηθούν: εμφανίζονται ως πλαίσια με σταυρό @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} συνδεδεμένη εικόνα άλλαξε στον δίσκο: η Ενημέρωση συνδέσμων εμφανίζει τη νέα έκδοση|{n} συνδεδεμένες εικόνες άλλαξαν στον δίσκο: η Ενημέρωση συνδέσμων εμφανίζει τις νέες εκδόσεις @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Ενημερώθηκε {n} συνδεδεμένο αρχείο που άλλαξε στον δίσκο|Ενημερώθηκαν {n} συνδεδεμένα αρχεία που άλλαξαν στον δίσκο +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings το έγγραφο ορίζει ξάκρισμα που αυτό το αρχείο PDF/X παραλείπει: ενεργοποιήστε τη «Χρήση ρυθμίσεων ξακρίσματος εγγράφου» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Εξήχθη το {what} με {n} σημείωση: {first}|Εξήχθη το {what} με {n} σημειώσεις: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} αρχείο γραμματοσειράς βρίσκεται πλέον στον φάκελο {folder}|{n} αρχεία γραμματοσειρών βρίσκονται πλέον στον φάκελο {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} αρχείο γραμματοσειράς βρίσκεται πλέον στον φάκελο {folder}· δεν ήταν δυνατή η προσθήκη {failed}: {reason}|{n} αρχεία γραμματοσειρών βρίσκονται πλέον στον φάκελο {folder}· δεν ήταν δυνατή η προσθήκη {failed}: {reason} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aff3c9b87..ee8dd703f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4624,6 +4624,7 @@ axis Both Ambos @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes no se pudo descodificar {n} imagen: se muestra como un recuadro tachado|no se pudieron descodificar {n} imágenes: se muestran como recuadros tachados @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} imagen vinculada cambió en el disco: Actualizar vínculos muestra la versión nueva|{n} imágenes vinculadas cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Se actualizó {n} archivo vinculado que cambió en el disco|Se actualizaron {n} archivos vinculados que cambiaron en el disco +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings el documento define un sangrado que este archivo PDF/X omite: activa «Usar los ajustes de sangrado del documento» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Se exportó {what} con {n} nota: {first}|Se exportó {what} con {n} notas: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} archivo de fuentes ahora está en {folder}|{n} archivos de fuentes ahora están en {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} archivo de fuentes ahora está en {folder}; no se pudieron agregar {failed}: {reason}|{n} archivos de fuentes ahora están en {folder}; no se pudieron agregar {failed}: {reason} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 58da7396e..8bc4d5163 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4832,6 +4832,7 @@ imageTrace Outline Ääriviiva @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} kuvaa ei voitu purkaa: se näkyy ristillä merkittynä ruutuna|{n} kuvaa ei voitu purkaa: ne näkyvät ristillä merkittyinä ruutuina @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} linkitetty kuva muuttui levyllä: Päivitä linkit näyttää uuden version|{n} linkitettyä kuvaa muuttui levyllä: Päivitä linkit näyttää uudet versiot @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Päivitettiin {n} levyllä muuttunut linkitetty tiedosto|Päivitettiin {n} levyllä muuttunutta linkitettyä tiedostoa +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings asiakirjassa on leikkuuvara, jonka tämä PDF/X-tiedosto jättää pois: ota käyttöön ”Käytä asiakirjan leikkuuvara-asetuksia” @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Vietiin {what}, {n} huomautus: {first}|Vietiin {what}, {n} huomautusta: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} fonttitiedosto on nyt kansiossa {folder}|{n} fonttitiedostoa on nyt kansiossa {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} fonttitiedosto on nyt kansiossa {folder}; {failed} ei voitu lisätä: {reason}|{n} fonttitiedostoa on nyt kansiossa {folder}; {failed} ei voitu lisätä: {reason} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fe935bf47..4ffec6fcb 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4612,6 +4612,7 @@ axis Both Les deux @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} image impossible à décoder, affichée sous forme de cadre barré d’une croix|{n} images impossibles à décoder, affichées sous forme de cadres barrés d’une croix @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} image liée modifiée sur le disque : Mettre à jour les liens affiche la nouvelle version|{n} images liées modifiées sur le disque : Mettre à jour les liens affiche les nouvelles versions @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk {n} fichier lié modifié sur le disque mis à jour|{n} fichiers liés modifiés sur le disque mis à jour +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings le document définit un fond perdu que ce fichier PDF/X omet : activez « Utiliser les paramètres de fond perdu du document » @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} exporté avec {n} remarque : {first}|{what} exporté avec {n} remarques : {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} fichier de polices se trouve maintenant dans {folder}|{n} fichiers de polices se trouvent maintenant dans {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} fichier de polices se trouve maintenant dans {folder} ; {failed} n’ont pas pu être ajoutés : {reason}|{n} fichiers de polices se trouvent maintenant dans {folder} ; {failed} n’ont pas pu être ajoutés : {reason} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index ea6911fd2..f14b40c0c 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7746,6 +7746,7 @@ @msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} {n} डॉक्यूमेंट एक्सपोर्ट हुआ; {title} लिखा नहीं गया: {error}|{n} डॉक्यूमेंट एक्सपोर्ट हुए; {title} लिखा नहीं गया: {error} @msgplural Exported {n} document|Exported {n} documents {n} डॉक्यूमेंट एक्सपोर्ट हुआ|{n} डॉक्यूमेंट एक्सपोर्ट हुए @msgplural Exported {n} file to {folder}|Exported {n} files to {folder} {folder} में {n} फ़ाइल एक्सपोर्ट हुई|{folder} में {n} फ़ाइलें एक्सपोर्ट हुईं +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings डॉक्यूमेंट एक ब्लीड सेट करता है जिसे यह PDF/X फ़ाइल छोड़ देती है: "डॉक्यूमेंट की ब्लीड सेटिंग्स उपयोग करें" चालू करें @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} एक्सपोर्ट हुआ, {n} नोट के साथ: {first}|{what} एक्सपोर्ट हुआ, {n} नोट्स के साथ: {first} @msgplural Opened with {n} note: {first}|Opened with {n} notes: {first} {n} नोट के साथ खुला: {first}|{n} नोट्स के साथ खुला: {first} @msgplural Packaged {n} file ({note}) in {folder}|Packaged {n} files ({note}) in {folder} {folder} में {n} फ़ाइल ({note}) पैकेज हुई|{folder} में {n} फ़ाइलें ({note}) पैकेज हुईं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c97563bee..966e52a74 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5125,6 +5125,7 @@ imageTrace Outline Körvonal @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes Nem dekódolható képek: {n}; áthúzott keretként látszanak @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions A lemezen módosult csatolt képek: {n}; a Csatolások frissítése az új változatokat mutatja @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Frissített, a lemezen módosult csatolt fájlok: {n} +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings a dokumentum kifutót állít be, amelyet ez a PDF/X-fájl kihagy: kapcsolja be „A dokumentum kifutóbeállításainak használata” beállítást @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Exportálva: {what}; megjegyzések: {n}, az első: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} Betűtípusfájlok száma: {n}, mappa: {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} Betűtípusfájlok száma: {n}, mappa: {folder}; nem sikerült hozzáadni: {failed} ({reason}) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9bd658195..0dc1c7974 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4562,6 +4562,7 @@ imageTrace Outline Kontur @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} gambar tidak dapat didekode dan ditampilkan sebagai kotak bersilang @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} gambar tertaut di disk telah berubah: Perbarui Tautan menampilkan versi barunya @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Telah memperbarui {n} berkas tertaut yang berubah di disk +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokumen menetapkan bleed yang dihilangkan oleh file PDF/X ini: aktifkan "Gunakan pengaturan bleed dokumen" @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} diekspor dengan {n} catatan: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} berkas font kini berada di {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} berkas font kini berada di {folder}; {failed} tidak dapat ditambahkan: {reason} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 38b72c8c5..97426df98 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4666,6 +4666,7 @@ axis Both Entrambi @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} immagine non decodificabile: viene mostrata come riquadro barrato da una croce|{n} immagini non decodificabili: vengono mostrate come riquadri barrati da una croce @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} immagine collegata modificata su disco: Aggiorna collegamenti mostra la nuova versione|{n} immagini collegate modificate su disco: Aggiorna collegamenti mostra le nuove versioni @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Aggiornato {n} file collegato modificato su disco|Aggiornati {n} file collegati modificati su disco +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings il documento imposta un'abbondanza che questo file PDF/X omette: attiva «Usa impostazioni abbondanza del documento» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Esportato {what} con {n} nota: {first}|Esportato {what} con {n} note: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} file di font ora in {folder}|{n} file di font ora in {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} file di font ora in {folder}; impossibile aggiungerne {failed}: {reason}|{n} file di font ora in {folder}; impossibile aggiungerne {failed}: {reason} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4c25ed744..53bf5ed0f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4737,6 +4737,7 @@ swatch Bright Violet ビビッドバイオレット @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} 個の画像をデコードできませんでした: バツ印の付いた枠で表示しています @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk ディスク上で変更された {n} 個のリンクファイルを更新しました +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings ドキュメントに設定された裁ち落としがこの PDF/X ファイルには含まれません: 「ドキュメントの裁ち落とし設定を使用」をオンにしてください @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} を書き出しましたが、注意事項が {n} 件あります: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} 個のフォントファイルが {folder} に入りました @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} 個のフォントファイルが {folder} に入りましたが、{failed} 個は追加できませんでした: {reason} diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 1bada5e40..c3c0e35b7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4362,6 +4362,7 @@ font-technology Variable ცვლადი @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes სურათების დეკოდირება ვერ მოხერხდა ({n}): ისინი ჯვრით გადახაზული ჩარჩოებით ჩანს @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions მიბმული გამოსახულებები შეიცვალა დისკზე ({n}): ბმულების განახლება აჩვენებს ახალ ვერსიებს @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk განახლდა დისკზე შეცვლილი დაკავშირებული ფაილები: {n} +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings დოკუმენტი ადგენს ჭრისპირა არეს, რომელსაც ეს PDF/X ფაილი გამოტოვებს: ჩართეთ „დოკუმენტის ჭრისპირა არის პარამეტრების გამოყენება“ @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} ექსპორტირებულია {what}, შენიშვნები: {n}: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} შრიფტის ფაილები ({n}) ახლა აქ არის: {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} შრიფტის ფაილები ({n}) ახლა აქ არის: {folder}; ვერ დაემატა {failed}: {reason} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1039fba60..1d8bcd543 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4664,6 +4664,7 @@ theme Appearance 외관 @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes 이미지 {n}개를 디코딩할 수 없습니다: X 표시가 된 상자로 표시됩니다 @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions 링크된 이미지 {n}개가 디스크에서 바뀌었습니다: 링크 업데이트를 하면 새 버전이 표시됩니다 @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk 디스크에서 바뀐 링크된 파일 {n}개를 업데이트했습니다 +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings 문서에 설정된 도련이 이 PDF/X 파일에서 빠집니다: "문서 도련 설정 사용"을 켜세요 @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} 참고 {n}건과 함께 {what}을(를) 내보냈습니다: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} 글꼴 파일 {n}개가 이제 {folder}에 있습니다 @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} 글꼴 파일 {n}개가 이제 {folder}에 있으며, {failed}개는 추가할 수 없었습니다: {reason} diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 00431e1a9..b1d22ac50 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4574,6 +4574,7 @@ imageTrace Outline Contour @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} afbeelding kon niet worden gedecodeerd en wordt als doorgekruist vak weergegeven|{n} afbeeldingen konden niet worden gedecodeerd en worden als doorgekruiste vakken weergegeven @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} gekoppelde afbeelding is gewijzigd op de schijf: Koppelingen bijwerken toont de nieuwe versie|{n} gekoppelde afbeeldingen zijn gewijzigd op de schijf: Koppelingen bijwerken toont de nieuwe versies @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Bijgewerkt: {n} gekoppeld bestand gewijzigd op de schijf|Bijgewerkt: {n} gekoppelde bestanden gewijzigd op de schijf +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings het document stelt een afloop in die dit PDF/X-bestand weglaat: schakel 'Afloopinstellingen van document gebruiken' in @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} geëxporteerd met {n} opmerking: {first}|{what} geëxporteerd met {n} opmerkingen: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} er staat nu {n} lettertypebestand in {folder}|er staan nu {n} lettertypebestanden in {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} er staat nu {n} lettertypebestand in {folder}; {failed} konden niet worden toegevoegd: {reason}|er staan nu {n} lettertypebestanden in {folder}; {failed} konden niet worden toegevoegd: {reason} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3b52097d4..9261962a8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4756,6 +4756,7 @@ imageTrace Outline Kontur @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes nie udało się zdekodować {n} obrazu: jest wyświetlany jako przekreślona ramka|nie udało się zdekodować {n} obrazów: są wyświetlane jako przekreślone ramki|nie udało się zdekodować {n} obrazów: są wyświetlane jako przekreślone ramki @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} połączony obraz zmienił się na dysku: Aktualizuj łącza pokaże nową wersję|{n} połączone obrazy zmieniły się na dysku: Aktualizuj łącza pokaże nowe wersje|{n} połączonych obrazów zmieniło się na dysku: Aktualizuj łącza pokaże nowe wersje @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Zaktualizowano {n} połączony plik zmieniony na dysku|Zaktualizowano {n} połączone pliki zmienione na dysku|Zaktualizowano {n} połączonych plików zmienionych na dysku +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokument ustawia spad, który ten plik PDF/X pomija: włącz „Użyj ustawień spadu dokumentu” @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Wyeksportowano {what} z {n} uwagą: {first}|Wyeksportowano {what} z {n} uwagami: {first}|Wyeksportowano {what} z {n} uwagami: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} plik czcionki jest teraz w {folder}|{n} pliki czcionek są teraz w {folder}|{n} plików czcionek jest teraz w {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} plik czcionki jest teraz w {folder}; nie udało się dodać {failed}: {reason}|{n} pliki czcionek są teraz w {folder}; nie udało się dodać {failed}: {reason}|{n} plików czcionek jest teraz w {folder}; nie udało się dodać {failed}: {reason} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 219c6e8e7..2a05465fa 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4201,6 +4201,7 @@ axis Both Ambos @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes não foi possível decodificar {n} imagem: ela aparece como uma caixa marcada com um X|não foi possível decodificar {n} imagens: elas aparecem como caixas marcadas com um X @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} imagem vinculada mudou no disco: Atualizar links mostra a nova versão|{n} imagens vinculadas mudaram no disco: Atualizar links mostra as novas versões @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk {n} arquivo vinculado alterado no disco atualizado|{n} arquivos vinculados alterados no disco atualizados +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings o documento define uma sangria que este arquivo PDF/X omite: ative "Usar configurações de sangria do documento" @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} exportado com {n} observação: {first}|{what} exportado com {n} observações: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} arquivo de fonte agora está em {folder}|{n} arquivos de fonte agora estão em {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} arquivo de fonte agora está em {folder}; não foi possível adicionar {failed}: {reason}|{n} arquivos de fonte agora estão em {folder}; não foi possível adicionar {failed}: {reason} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1186b6df8..45bebb11c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4661,6 +4661,7 @@ axis Both Оба @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes не удалось декодировать {n} изображение: оно показано как перечёркнутая рамка|не удалось декодировать {n} изображения: они показаны как перечёркнутые рамки|не удалось декодировать {n} изображений: они показаны как перечёркнутые рамки @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} связанное изображение изменилось на диске: «Обновить связи» покажет новую версию|{n} связанных изображения изменились на диске: «Обновить связи» покажет новые версии|{n} связанных изображений изменились на диске: «Обновить связи» покажет новые версии @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Обновлён {n} связанный файл, изменённый на диске|Обновлено {n} связанных файла, изменённых на диске|Обновлено {n} связанных файлов, изменённых на диске +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings в документе задан вылет, который этот файл PDF/X не включает: включите «Использовать параметры вылета документа» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Экспортировано: {what} с {n} примечанием: {first}|Экспортировано: {what} с {n} примечаниями: {first}|Экспортировано: {what} с {n} примечаниями: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} файл шрифта теперь в {folder}|{n} файла шрифтов теперь в {folder}|{n} файлов шрифтов теперь в {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} файл шрифта теперь в {folder}; не удалось добавить {failed}: {reason}|{n} файла шрифтов теперь в {folder}; не удалось добавить {failed}: {reason}|{n} файлов шрифтов теперь в {folder}; не удалось добавить {failed}: {reason} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c491e1abf..03f98e9fa 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4523,6 +4523,7 @@ axis Both Båda @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} bild kunde inte avkodas och visas som en överkryssad ruta|{n} bilder kunde inte avkodas och visas som överkryssade rutor @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} länkad bild har ändrats på disken: Uppdatera länkar visar den nya versionen|{n} länkade bilder har ändrats på disken: Uppdatera länkar visar de nya versionerna @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Uppdaterade {n} länkad fil som ändrats på disken|Uppdaterade {n} länkade filer som ändrats på disken +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings dokumentet anger ett utfall som den här PDF/X-filen utelämnar: slå på ”Använd dokumentets inställningar för utfall” @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Exporterade {what} med {n} anteckning: {first}|Exporterade {what} med {n} anteckningar: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} typsnittsfil finns nu i {folder}|{n} typsnittsfiler finns nu i {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} typsnittsfil finns nu i {folder}; {failed} kunde inte läggas till: {reason}|{n} typsnittsfiler finns nu i {folder}; {failed} kunde inte läggas till: {reason} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 933f5a329..008f6924a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1903,6 +1903,20 @@ fn dataset_import_repeated_column_error_is_translated() { } } +/// The warning for a PDF/X file that leaves out the document's bleed is translated in every catalog, +/// alone and in the status bar's note. +#[test] +fn pdfx_bleed_warning_is_translated() { + let w = vectorcraft_engine::cmd::fileio::pdf::BLEED_LEFT_OUT; + let status = format!("Saved /tmp/a.pdf with 1 note: {w}"); + // A catalog's `{a}: {b}` row can translate the colon alone, so the check looks for the English words. + for lang in Lang::all().filter(|l| l.code() != Lang::EN.code()) { + for shown in [message(lang, w), message(lang, &status)] { + assert!(!shown.contains("leaves out") && !shown.contains("turn on"), "{}: {shown}", lang.code()); + } + } +} + /// The errors of saving, renaming and deleting a tracing preset are translated in every language /// that translates every message. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 20cad5f53..abaee4003 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4358,6 +4358,7 @@ axis Both Her İkisi @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} görüntünün kodu çözülemedi: çarpı işaretli kutular olarak gösteriliyor @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} bağlantılı görsel diskte değişti: Bağlantıları Güncelle yeni sürümleri gösterir @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Diskte değişen {n} bağlantılı dosya güncellendi +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings belge, bu PDF/X dosyasının dışarıda bıraktığı bir taşma payı ayarlıyor: "Belge taşma payı ayarlarını kullan" seçeneğini açın @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} {what} {n} notla dışa aktarıldı: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} yazı tipi dosyası artık {folder} içinde @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} yazı tipi dosyası artık {folder} içinde; {failed} eklenemedi: {reason} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 11f6bed60..140970789 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4650,6 +4650,7 @@ axis Both Обидві @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes не вдалося декодувати {n} зображення: воно показане як перекреслена рамка|не вдалося декодувати {n} зображення: вони показані як перекреслені рамки|не вдалося декодувати {n} зображень: вони показані як перекреслені рамки @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} пов’язане зображення змінилося на диску: «Оновити зв’язки» покаже нову версію|{n} пов’язані зображення змінилися на диску: «Оновити зв’язки» покаже нові версії|{n} пов’язаних зображень змінилися на диску: «Оновити зв’язки» покаже нові версії @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Оновлено {n} пов’язаний файл, змінений на диску|Оновлено {n} пов’язані файли, змінені на диску|Оновлено {n} пов’язаних файлів, змінених на диску +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings у документі задано виліт, який цей файл PDF/X не містить: увімкніть «Використовувати параметри вильоту документа» @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Експортовано: {what} з {n} приміткою: {first}|Експортовано: {what} з {n} примітками: {first}|Експортовано: {what} з {n} примітками: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} файл шрифту тепер у {folder}|{n} файли шрифтів тепер у {folder}|{n} файлів шрифтів тепер у {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} файл шрифту тепер у {folder}; не вдалося додати {failed}: {reason}|{n} файли шрифтів тепер у {folder}; не вдалося додати {failed}: {reason}|{n} файлів шрифтів тепер у {folder}; не вдалося додати {failed}: {reason} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f470e83b8..f3a323200 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4779,6 +4779,7 @@ theme Appearance Giao diện @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes {n} hình ảnh không thể giải mã: chúng hiển thị dưới dạng khung có dấu gạch chéo @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions {n} ảnh liên kết đã thay đổi trên đĩa: Cập nhật liên kết hiển thị phiên bản mới @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk Đã cập nhật {n} tệp liên kết đã thay đổi trên đĩa +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings tài liệu đặt bleed mà tệp PDF/X này bỏ qua: hãy bật "Dùng cài đặt bleed của tài liệu" @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} Đã xuất {what} với {n} ghi chú: {first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} tệp phông chữ hiện ở trong {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} tệp phông chữ hiện ở trong {folder}; {failed} không thể thêm: {reason} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0f0f90caa..2ed9a0c3d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4453,6 +4453,7 @@ imageTrace Outline 轮廓 @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes 无法解码 {n} 张图像:它们显示为打叉的方框 @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions 有 {n} 个链接图像已在磁盘上更改:“更新链接”可显示新版本 @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk 已更新 {n} 个在磁盘上更改的链接文件 +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings 文档设置了出血,但此 PDF/X 文件未包含:请打开“使用文档的出血设置” @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} 已导出 {what},有 {n} 条注释:{first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} 个字体文件现已位于 {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} 个字体文件现已位于 {folder};有 {failed} 个无法添加:{reason} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ccb88d64c..63d2e0f24 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2331,6 +2331,7 @@ file-filter Templates 範本 @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes 無法解碼 {n} 張影像:它們顯示為打叉的方框 @msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions 有 {n} 個連結影像已在磁碟上變更:「更新連結」可顯示新版本 @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk 已更新 {n} 個在磁碟上變更的連結檔案 +@msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings 文件設定了出血,但此 PDF/X 檔案未包含:請開啟「使用文件的出血設定」 @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} 已匯出 {what},附有 {n} 則註解:{first} @msgplural {n} font file is now in {folder}|{n} font files are now in {folder} {n} 個字體檔案現已位於 {folder} @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} 個字體檔案現已位於 {folder};有 {failed} 個無法加入:{reason} diff --git a/docs/mcp.md b/docs/mcp.md index d3fe607e7..c15e2f6ff 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3210,8 +3210,9 @@ the preference `japaneseCropMarks` is on or `style: "japanese"` is given. PDF export draws printer's marks and bleed itself (the Save PDF dialog's Marks and Bleeds section): `document.exportPdf {marks: {trim, registration, colorBars, pageInfo, kind, weight, offset}, bleed: {useDocument, top, bottom, left, right}}`. Each page's TrimBox is its artboard, its BleedBox the artboard grown by the bleed (the -document's with `useDocument`, set by `document.setup {bleed}`; the art there is kept) and its MediaBox the bleed box -grown to hold the marks (the art is clipped to the bleed box). Trim marks, registration targets and the page +document's with `useDocument`, set by `document.setup {bleed}`; the art there is kept; a PDF/X file with no bleed on a +side where the document's bleed has one is written as asked and comes back with a warning) and its MediaBox the bleed +box grown to hold the marks (the art is clipped to the bleed box). Trim marks, registration targets and the page information (title, artboard, export date in UTC) are drawn in Registration (`/Separation /All`) outside the bleed; the colour bars are process, spot and black-tint patches. Layers whose Print option is off (`layer.setProps {printable: false}`) are left out unless `includeNonPrinting` (or `createLayers`) is on. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0bed907fa..2e6431761 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1262,6 +1262,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. + PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). In a `.ai` or EPS opened from its editing data, and in a legacy `.ai`, each anchor is smooth or a corner as the case of the segment operator ending on it says (`l`, `c`, `v`, `y` smooth; `L`, `C`, `V`, `Y` corner), and a closed path's first anchor takes the case of the segment back to it; points and handles stay where they are. Paths, compound paths, clipping paths, guide paths, area type frames and type-on-a-path paths all keep it (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/legacy_ai/tests.rs`). From 670880f686e6ac73c5ce454b6484cd08d7c33382 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 22:59:50 -0800 Subject: [PATCH 51/52] Font menu: the wasm build has no unread wheel-field warnings The queued and replayed wheel events of the font menu are read only by native-only code (the viewport-layer plugin and the replay loop). Their fields that only that code reads (frame, menu, position, event and amount of QueuedWheel, and target of ReplayWheel) and the push that fills the queue now carry the same cfg(not(target_arch = "wasm32")) as their readers, so `cargo check --target wasm32-unknown-unknown -p vectorcraft-ui-egui` no longer warns that they are never read. Native builds do not change. --- crates/ui-egui/src/font_menu.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 39dd49bfc..1896b8ed9 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -443,12 +443,17 @@ struct WheelField { #[derive(Clone)] struct QueuedWheel { + #[cfg(not(target_arch = "wasm32"))] frame: u64, field: egui::Id, + #[cfg(not(target_arch = "wasm32"))] menu: egui::Id, + #[cfg(not(target_arch = "wasm32"))] position: egui::Pos2, rect: egui::Rect, + #[cfg(not(target_arch = "wasm32"))] event: egui::Event, + #[cfg(not(target_arch = "wasm32"))] amount: f32, reset_after: bool, positions_after: Vec, @@ -456,6 +461,7 @@ struct QueuedWheel { #[derive(Clone)] struct ReplayWheel { + #[cfg(not(target_arch = "wasm32"))] target: egui::LayerId, position: egui::Pos2, event: egui::Event, @@ -791,6 +797,7 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y, }; if cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() != raw.viewport_id { + #[cfg(not(target_arch = "wasm32"))] if let Some(position) = pointer { fields.queued.push(QueuedWheel { frame, From 8f16d153e4f383420c31f5c93be0b29a0b5e56fe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 23:53:55 -0800 Subject: [PATCH 52/52] Keyboard Shortcuts: Export Text saves the shortcut list as a text file Edit > Keyboard Shortcuts gains Export Text..., which saves the set shown in the dialog, as edited so far, as a UTF-8 text file the user names in a save dialog with a .txt filter. shortcuts.exportText {path?} does the same for the current set over MCP and the control channel. The file holds a "Keyboard Shortcuts" line and the set's name, then a Tools and a Menu Commands section with the rows the dialog lists, in its order and named as it shows them: the name, a tab and the shortcuts as the dialog shows them on the current platform. A row with no shortcut ends after the tab. A path that is not a string, or a file that cannot be written, returns an error and nothing is written. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/picks.rs | 1 + crates/ui-egui/src/shortcut_editor.rs | 139 +++++++++++++++++++++++- crates/ui-egui/src/tests/picks.rs | 32 ++++++ crates/ui-egui/src/tests/saveext.rs | 2 + docs/mcp.md | 14 +++ docs/target-app-parity.md | 1 + 34 files changed, 248 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e6914ada9..aed900147 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1060,6 +1060,8 @@ font-technology Variable Променливи Export for Screens Settings Настройки за експортиране за екрани Export for Screens… Експортиране за екрани… Export Keyboard Shortcuts… Експортиране на клавишни комбинации… + Export Keyboard Shortcuts as Text… Експортиране на клавишни комбинации като текст… + Export Text… Експортиране на текст… Export Option Опция за експортиране Export PDF Експортиране в PDF Export PDF Presets Предварителни настройки за PDF diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 9bdeb8492..eef02974f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -938,6 +938,8 @@ lighting Light Svjetlo Expand Layers Panel Rows Proširi redove panela slojeva Export CSS… Izvezi CSS… Export Keyboard Shortcuts… Izvezi prečice na tastaturi… + Export Keyboard Shortcuts as Text… Izvezi prečice na tastaturi kao tekst… + Export Text… Izvezi tekst… File Info Dialog Dijalog informacija o datoteci Go to Artboard Idi na radnu ploču First Artboard Prva radna ploča diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f28e1c8fd..dbf6d691f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1466,6 +1466,8 @@ Export for Screens Settings Configuració d’exportació per a pantalles Export for Screens… Exporta per a pantalles… Export Keyboard Shortcuts… Exporta les dreceres de teclat… + Export Keyboard Shortcuts as Text… Exporta les dreceres de teclat com a text… + Export Text… Exporta el text… Export Layer Exporta la capa Export Library Exporta la biblioteca Export Library… Exporta la biblioteca… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 18dba1eec..6711c58ef 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -957,6 +957,8 @@ lighting Light Světlo Expand Layers Panel Rows Rozbalit řádky panelu Vrstvy Export CSS… Exportovat CSS… Export Keyboard Shortcuts… Exportovat klávesové zkratky… + Export Keyboard Shortcuts as Text… Exportovat klávesové zkratky jako text… + Export Text… Exportovat text… File Info Dialog Dialogové okno Informace o souboru Go to Artboard Přejít na pracovní plochu First Artboard První pracovní plocha diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index c506c7106..de8fa2ed6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -878,6 +878,8 @@ lighting Light Lys Expand Layers Panel Rows Udvid rækker i lagpanelet Export CSS… Eksportér CSS… Export Keyboard Shortcuts… Eksportér tastaturgenveje… + Export Keyboard Shortcuts as Text… Eksportér tastaturgenveje som tekst… + Export Text… Eksportér tekst… File Info Dialog Dialogboks til filoplysninger Go to Artboard Gå til tegnebræt Graphic Style Options… Indstillinger for grafisk typografi… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b2588ed89..67c5e629f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1060,6 +1060,8 @@ font-technology Variable Variabel Export for Screens Settings Einstellungen für „Für Bildschirme exportieren“ Export for Screens… Für Bildschirme exportieren… Export Keyboard Shortcuts… Tastaturbefehle exportieren… + Export Keyboard Shortcuts as Text… Tastaturbefehle als Text exportieren… + Export Text… Text exportieren… Export Option Exportoption Export PDF PDF exportieren Export PDF Presets PDF-Vorgaben exportieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 497682bf9..d9d5614a2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1033,6 +1033,8 @@ font-technology Variable Μεταβλητές Export for Screens Settings Ρυθμίσεις εξαγωγής για οθόνες Export for Screens… Εξαγωγή για οθόνες… Export Keyboard Shortcuts… Εξαγωγή συντομεύσεων πληκτρολογίου… + Export Keyboard Shortcuts as Text… Εξαγωγή συντομεύσεων πληκτρολογίου ως κείμενο… + Export Text… Εξαγωγή κειμένου… Export Option Επιλογή εξαγωγής Export PDF Εξαγωγή PDF Export PDF Presets Εξαγωγή προεπιλογών PDF diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ee8dd703f..678f97bb5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1089,6 +1089,8 @@ font-technology Variable Variables Export for Screens Settings Ajustes de Exportar para pantallas Export for Screens… Exportar para pantallas… Export Keyboard Shortcuts… Exportar métodos abreviados de teclado… + Export Keyboard Shortcuts as Text… Exportar métodos abreviados de teclado como texto… + Export Text… Exportar texto… Export Option Opción de exportación Export PDF Exportar PDF Export PDF Presets Exportar preajustes de PDF diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8bc4d5163..ef68bedd1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -936,6 +936,8 @@ lighting Light Valo Expand Layers Panel Rows Laajenna tasopaneelin rivit Export CSS… Vie CSS… Export Keyboard Shortcuts… Vie pikanäppäimet… + Export Keyboard Shortcuts as Text… Vie pikanäppäimet tekstinä… + Export Text… Vie teksti… File Info Dialog Tiedoston tiedot -valintaikkuna Go to Artboard Siirry piirtoalustaan First Artboard Ensimmäinen piirtoalusta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4ffec6fcb..80344c35d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1084,6 +1084,8 @@ font-technology Variable Variables Export for Screens Settings Paramètres d’Exporter pour les écrans Export for Screens… Exporter pour les écrans… Export Keyboard Shortcuts… Exporter les raccourcis clavier… + Export Keyboard Shortcuts as Text… Exporter les raccourcis clavier en texte… + Export Text… Exporter le texte… Export Option Option d’exportation Export PDF Exporter en PDF Export PDF Presets Exporter les paramètres prédéfinis PDF diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index f14b40c0c..d181ae8dd 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1631,6 +1631,8 @@ Export for Screens Settings स्क्रीन सेटिंग के लिए निर्यात Export for Screens… स्क्रीन के लिए निर्यात… Export Keyboard Shortcuts… कीबोर्ड शॉर्टकट निर्यात करें… + Export Keyboard Shortcuts as Text… कीबोर्ड शॉर्टकट टेक्स्ट के रूप में निर्यात करें… + Export Text… टेक्स्ट निर्यात करें… Export Layer निर्यात लेयर Export Library लाइब्रेरी Export Library… लाइब्रेरी निर्यात करें… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 966e52a74..dea4dff15 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1367,6 +1367,8 @@ font-technology Variable Változó Export for Screens Settings Exportálás képernyőkre – beállítások Export for Screens… Exportálás képernyőkre… Export Keyboard Shortcuts… Billentyűparancsok exportálása… + Export Keyboard Shortcuts as Text… Billentyűparancsok exportálása szövegként… + Export Text… Szöveg exportálása… Export Library Könyvtár exportálása Export Library… Könyvtár exportálása… Export Option Exportálási beállítás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0dc1c7974..837d84f32 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -878,6 +878,8 @@ lighting Light Cahaya Expand Layers Panel Rows Perluas baris panel Lapisan Export CSS… Ekspor CSS… Export Keyboard Shortcuts… Ekspor pintasan papan ketik… + Export Keyboard Shortcuts as Text… Ekspor pintasan papan ketik sebagai teks… + Export Text… Ekspor teks… File Info Dialog Dialog info file Go to Artboard Buka artboard Graphic Style Options… Opsi gaya grafis… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 97426df98..dfa7856d4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1090,6 +1090,8 @@ font-technology Variable Variabili Export for Screens Settings Impostazioni Esporta per schermi Export for Screens… Esporta per schermi… Export Keyboard Shortcuts… Esporta scelte rapide da tastiera… + Export Keyboard Shortcuts as Text… Esporta scelte rapide da tastiera come testo… + Export Text… Esporta testo… Export Option Opzione di esportazione Export PDF Esporta PDF Export PDF Presets Esporta preset PDF diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 53bf5ed0f..a2bbe2630 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1581,6 +1581,8 @@ digits Malayalam マラヤーラム数字 Export Document ドキュメントを書き出し Export EPS EPS を書き出し Export Keyboard Shortcuts… キーボードショートカットを書き出し… + Export Keyboard Shortcuts as Text… キーボードショートカットをテキストで書き出し… + Export Text… テキストを書き出し… Export Option 書き出しオプション Export PDF PDF を書き出し Export PDF Presets PDF プリセットを書き出し diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c3c0e35b7..66d7e438a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -831,6 +831,8 @@ lighting Light სინათლე Expand Layers Panel Rows შრეების პანელის სტრიქონების გაშლა Export CSS… CSS-ის ექსპორტირება… Export Keyboard Shortcuts… კლავიატურის მალსახმობების ექსპორტირება… + Export Keyboard Shortcuts as Text… კლავიატურის მალსახმობების ტექსტად ექსპორტირება… + Export Text… ტექსტის ექსპორტი… File Info Dialog ფაილის ინფორმაციის დიალოგი Go to Artboard სამუშაო დაფაზე გადასვლა Graphic Style Options… გრაფიკული სტილის პარამეტრები… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1d8bcd543..2f8a0d7c4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1571,6 +1571,8 @@ digits Malayalam 말라얄람어 Export Document 문서 내보내기 Export EPS EPS 내보내기 Export Keyboard Shortcuts… 단축키 내보내기… + Export Keyboard Shortcuts as Text… 단축키를 텍스트로 내보내기… + Export Text… 텍스트 내보내기… Export Option 내보내기 옵션 Export PDF PDF 내보내기 Export PDF Presets PDF 프리셋 내보내기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b1d22ac50..cb4562bb5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -892,6 +892,8 @@ lighting Light Licht Expand Layers Panel Rows Rijen in het Lagen-paneel uitvouwen Export CSS… CSS exporteren… Export Keyboard Shortcuts… Sneltoetsen exporteren… + Export Keyboard Shortcuts as Text… Sneltoetsen als tekst exporteren… + Export Text… Tekst exporteren… File Info Dialog Dialoogvenster bestandsinfo Go to Artboard Ga naar tekengebied Graphic Style Options… Opties voor grafische stijl… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9261962a8..eb5a3aebd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -894,6 +894,8 @@ lighting Light Światło Expand Layers Panel Rows Rozwiń wiersze panelu Warstwy Export CSS… Eksportuj CSS… Export Keyboard Shortcuts… Eksportuj skróty klawiaturowe… + Export Keyboard Shortcuts as Text… Eksportuj skróty klawiaturowe jako tekst… + Export Text… Eksportuj tekst… File Info Dialog Okno informacji o pliku Go to Artboard Przejdź do obszaru kompozycji First Artboard Pierwszy obszar kompozycji diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2a05465fa..d3e4a7c6d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -981,6 +981,8 @@ Export Document Exportar Documento Export EPS Exportar EPS Export Keyboard Shortcuts… Exportar Atalhos de Teclado… + Export Keyboard Shortcuts as Text… Exportar Atalhos de Teclado como Texto… + Export Text… Exportar Texto… Export Option Opção de Exportação Export PDF Exportar PDF Export PDF Presets Exportar Predefinidos de PDF diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 45bebb11c..b3fa57493 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1087,6 +1087,8 @@ font-technology Variable Вариативные Export for Screens Settings Параметры экспорта для экранов Export for Screens… Экспортировать для экранов… Export Keyboard Shortcuts… Экспортировать сочетания клавиш… + Export Keyboard Shortcuts as Text… Экспортировать сочетания клавиш как текст… + Export Text… Экспорт текста… Export Option Параметр экспорта Export PDF Экспортировать PDF Export PDF Presets Экспортировать наборы PDF diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 03f98e9fa..5e48f7f81 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1068,6 +1068,8 @@ font-technology Variable Variabla Export for Screens Settings Inställningar för Exportera för skärmar Export for Screens… Exportera för skärmar… Export Keyboard Shortcuts… Exportera kortkommandon… + Export Keyboard Shortcuts as Text… Exportera kortkommandon som text… + Export Text… Exportera text… Export Option Exportalternativ Export PDF Exportera PDF Export PDF Presets Exportera PDF-förinställningar diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index abaee4003..c4c515e56 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1060,6 +1060,8 @@ font-technology Variable Değişken Export for Screens Settings Ekran Ayarları için Dışa Aktarma Export for Screens… Ekranlar için Dışa Aktarma… Export Keyboard Shortcuts… Klavye Kısayollarını Dışa Aktar… + Export Keyboard Shortcuts as Text… Klavye Kısayollarını Metin Olarak Dışa Aktar… + Export Text… Metni Dışa Aktar… Export Option Dışa Aktarma Seçeneği Export PDF PDF'yi dışa aktar Export PDF Presets PDF Hazır Ayarlarını Dışa Aktar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 140970789..c6f793067 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1084,6 +1084,8 @@ font-technology Variable Варіативні Export for Screens Settings Параметри експорту для екранів Export for Screens… Експортувати для екранів… Export Keyboard Shortcuts… Експортувати сполучення клавіш… + Export Keyboard Shortcuts as Text… Експортувати сполучення клавіш як текст… + Export Text… Експорт тексту… Export Option Параметр експорту Export PDF Експортувати PDF Export PDF Presets Експортувати набори PDF diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3a323200..29f222849 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1244,6 +1244,8 @@ font-technology Variable Biến thiên Export for Screens Settings Cài đặt xuất cho màn hình Export for Screens… Xuất cho màn hình… Export Keyboard Shortcuts… Xuất phím tắt… + Export Keyboard Shortcuts as Text… Xuất phím tắt dưới dạng văn bản… + Export Text… Xuất văn bản… Export Library Xuất thư viện Export Library… Xuất thư viện… Export Option Tùy chọn xuất diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2ed9a0c3d..4f3d2dcd9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1030,6 +1030,8 @@ Export for Screens Settings 导出为屏幕所用格式设置 Export for Screens… 导出为屏幕所用格式… Export Keyboard Shortcuts… 导出键盘快捷键… + Export Keyboard Shortcuts as Text… 将键盘快捷键导出为文本… + Export Text… 导出文本… Export Option 导出选项 Export PDF 导出 PDF Export PDF Presets 导出 PDF 预设 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 63d2e0f24..ba3a80ef0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1054,6 +1054,8 @@ font-technology Variable 可變 Export for Screens Settings 匯出為螢幕適用設定 Export for Screens… 匯出為螢幕適用… Export Keyboard Shortcuts… 匯出鍵盤快速鍵… + Export Keyboard Shortcuts as Text… 將鍵盤快速鍵匯出為文字… + Export Text… 匯出文字… Export Option 匯出選項 Export PDF 匯出 PDF Export PDF Presets 匯出 PDF 預設集 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 41504f5e2..bb2b4d83b 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -167,6 +167,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)", ), ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), + ( + "shortcuts.exportText", + "Export Keyboard Shortcuts as Text…", + "", + "{path?} save the current set's tools and menu commands with their shortcuts as a UTF-8 text file (Keyboard Shortcuts › Export Text…): the set's name, then a Tools and a Menu Commands section in the dialog's order, one `nameshortcut` line per row, empty after the tab when it has none; without `path`, pick a file", + ), ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), ( "ui.shortcutImportDialog", diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 81253e83c..92314bfba 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -76,6 +76,7 @@ const DOCUMENT_FREE: &[&str] = &[ "ui.libraryPanel.locate", "shortcuts.import", "shortcuts.export", + "shortcuts.exportText", "ui.shortcutImportDialog", crate::panels::actions::SAVE_SET, crate::panels::actions::LOAD_SET, diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index ee2b6ea83..3836f3f18 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -700,6 +700,25 @@ pub fn import_json(v: &Value) -> Result<(String, Overrides), String> { Ok((v.get("set").and_then(Value::as_str).map_or(CUSTOM, set_name).to_string(), out)) } +/// Export Text: the set's name, then the dialog's Tools and Menu Commands lists in its order, one +/// row per line with the name and the shortcuts as the dialog shows them, separated by a tab. A +/// row with no shortcut ends after the tab. +pub fn export_text(set: &str, overrides: &Overrides) -> String { + let km = keymap_with(overrides); + let backtick = backtick_text(); + let shown_set = if PRESETS.contains(&set) || set == CUSTOM { tl!(set) } else { set }; + let mut out = format!("{}\n{} {shown_set}\n", tl!("Keyboard Shortcuts"), tl!("Set:")); + for (tools, heading) in [(true, "Tools"), (false, "Menu Commands")] { + out += &format!("\n{}\n", tl!(heading)); + for e in entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)) { + let name = if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) }; + let sc = dialog_shortcuts_with(&km, &e.key, backtick.as_deref()).unwrap_or_default(); + out += &format!("{name}\t{sc}\n"); + } + } + out +} + // ---------- UI commands ---------- pub fn open(app: &mut VectorcraftApp) { @@ -800,6 +819,31 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { + let path = match p.get("path") { + None | Some(Value::Null) => None, + Some(Value::String(path)) => Some(path.clone()), + Some(_) => { + let key = "path"; + return Some(Err(format!("`{key}` must be a string"))); + } + }; + let text = export_text(&app.ui.shortcut_set, &app.ui.shortcut_overrides); + let pick = + crate::FilePick { filters: vec![crate::FilePick::filter("Text", &["txt"])], ..crate::FilePick::named("VectorCraft Shortcuts.txt") }; + match path.or_else(|| crate::picks::save(app, &pick)) { + Some(path) => match app.services.write.as_mut() { + Some(w) => w(&path, text.as_bytes()).map(|_| json!({"path": path})), + None => Err("no file writer".into()), + }, + // Its save dialog is open: the file is written when it answers. + None if app.file_dialog_open() => Err("cancelled".into()), + None => match app.services.download.as_mut() { + Some(dl) => dl("VectorCraft Shortcuts.txt", text.as_bytes()).map(|()| Value::Null), + None => Ok(json!({"text": text})), + }, + } + } "shortcuts.import" => { let data = if let Some(v) = p.get("data") { Ok(v.clone()) @@ -947,6 +991,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if ui.button(tl!("Export…")).clicked() { d.fields.insert("__export".into(), json!(true)); } + if ui.button(tl!("Export Text…")).clicked() { + d.fields.insert("__exportText".into(), json!(true)); + } if ui.button(tl!("Reset to Defaults")).clicked() { km.to_mut().reset_all(); d.fields.insert("set".into(), json!(PRESETS[0])); @@ -1090,6 +1137,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } let import = d.fields.remove("__import").is_some(); let export = d.fields.remove("__export").is_some(); + let export_text = d.fields.remove("__exportText").is_some(); app.ui.dialog = Some(d); // Their file dialogs, shown off the UI thread, import into or export from the dialog as it is // when they answer. @@ -1099,6 +1147,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if export { crate::picks::button(app, export_from_dialog); } + if export_text { + crate::picks::button(app, export_text_from_dialog); + } if cancel { app.ui.dialog = None; } else if ok && let Err(e) = confirm(app) { @@ -1137,6 +1188,16 @@ fn import_into_dialog(app: &mut VectorcraftApp) { /// The dialog's Export…: its working set, as edited so far. fn export_from_dialog(app: &mut VectorcraftApp) { + export_dialog_set(app, "shortcuts.export"); +} + +/// The dialog's Export Text…: its working set, as edited so far, as a text file. +fn export_text_from_dialog(app: &mut VectorcraftApp) { + export_dialog_set(app, "shortcuts.exportText"); +} + +/// Runs `command` on the dialog's working set and set name. +fn export_dialog_set(app: &mut VectorcraftApp, command: &str) { if !shortcuts_dialog(app) { app.status(crate::picks::dialog_gone()); return; @@ -1144,7 +1205,7 @@ fn export_from_dialog(app: &mut VectorcraftApp) { let (ov, set) = app.ui.dialog.as_ref().map(|d| (dialog_overrides(d), d.str("set"))).unwrap_or_default(); let saved = std::mem::replace(&mut app.ui.shortcut_overrides, ov); let saved_set = std::mem::replace(&mut app.ui.shortcut_set, set); - if let Some(Err(e)) = run_command(app, "shortcuts.export", &json!({})) { + if let Some(Err(e)) = run_command(app, command, &json!({})) { app.status(e); } app.ui.shortcut_overrides = saved; @@ -1453,6 +1514,82 @@ mod tests { assert!(import_json(&json!({})).is_err()); } + /// An Export Text line: the row's name as the dialog shows it, a tab and `shortcut`. + fn text_row(key: &str, shortcut: &str) -> String { + let e = entry(key).unwrap_or_else(|| panic!("no entry {key}")); + if e.is_tool || e.group.is_empty() { format!("{}\t{shortcut}", e.label) } else { format!("{} › {}\t{shortcut}", e.group, e.label) } + } + + /// Export Text writes the set's name, then the Tools and the Menu Commands rows in the dialog's + /// order, each with its shortcut as the dialog shows it. + #[test] + fn export_text_lists_the_dialogs_rows_with_their_shortcuts() { + let mut ov = Overrides::new(); + crate::keymap::set_override(&mut ov, "edit.preferences", "Ctrl+Alt+Shift+9"); + let text = export_text("VectorCraft Defaults", &ov); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[..4], ["Keyboard Shortcuts", "Set: VectorCraft Defaults", "", "Tools"]); + let menu_at = lines.iter().position(|l| *l == "Menu Commands").expect("a Menu Commands section"); + assert_eq!(lines[menu_at - 1], "", "a blank line before Menu Commands"); + let km = keymap_with(&ov); + let names = |tools: bool| -> Vec { + entries().iter().filter(|e| e.is_tool == tools && listed(km.overrides(), e)).map(|e| text_row(&e.key, "")).collect() + }; + let names_of = |rows: &[&str]| -> Vec { rows.iter().map(|l| format!("{}\t", l.split('\t').next().unwrap_or_default())).collect() }; + assert_eq!(names_of(&lines[4..menu_at - 1]), names(true), "the Tools rows, in the dialog's order"); + assert_eq!(names_of(&lines[menu_at + 1..]), names(false), "the Menu Commands rows, in the dialog's order"); + let pen = menus::pretty_shortcut(default_of("tool:pen").expect("the Pen tool has a default key")); + assert!(lines[4..menu_at].contains(&text_row("tool:pen", &pen).as_str()), "{text}"); + let prefs = menus::pretty_shortcut(&effective_with(&km, "edit.preferences").expect("the shortcut set")); + assert!(prefs.ends_with('9'), "{prefs}"); + assert!(lines[menu_at..].contains(&text_row("edit.preferences", &prefs).as_str()), "{text}"); + assert!(text.ends_with('\n')); + } + + /// A command without a shortcut is listed with nothing after the tab: one without a default key + /// and one whose shortcut the set clears. + #[test] + fn export_text_lists_a_command_without_a_shortcut_with_an_empty_shortcut() { + let mut ov = Overrides::new(); + crate::keymap::set_override(&mut ov, "edit.preferences", ""); + assert!(default_of("align.horizontalLeft").is_none()); + let text = export_text(CUSTOM, &ov); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[1], "Set: Custom"); + for key in ["align.horizontalLeft", "edit.preferences"] { + assert!(lines.contains(&text_row(key, "").as_str()), "{key}: {text}"); + } + } + + /// `shortcuts.exportText` writes the set's name as the set was named, and a path that can't be + /// written or isn't a string gives an error and writes nothing. + #[test] + fn export_text_command_writes_the_sets_name_and_rejects_a_bad_path() { + type Files = std::rc::Rc>>; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let written: Files = Default::default(); + let w = written.clone(); + app.services.write = Some(Box::new(move |path: &str, b: &[u8]| { + if path.starts_with("/missing/") { + return Err(format!("can't write {path}")); + } + w.borrow_mut().push((path.to_string(), String::from_utf8(b.to_vec()).map_err(|e| e.to_string())?)); + Ok(()) + })); + app.run("shortcuts.import", json!({"data": export_json("Studio", &Overrides::new())})).unwrap(); + assert_eq!(app.run("shortcuts.exportText", json!({"path": "/sets/Studio.txt"})), Ok(json!({"path": "/sets/Studio.txt"}))); + assert_eq!( + app.run("shortcuts.exportText", json!({"path": "/missing/dir/Studio.txt"})), + Err("can't write /missing/dir/Studio.txt".to_string()) + ); + assert_eq!(app.run("shortcuts.exportText", json!({"path": 5})), Err("`path` must be a string".to_string())); + let files = written.borrow(); + let [(path, text)] = files.as_slice() else { panic!("{files:?}") }; + assert_eq!(path, "/sets/Studio.txt"); + assert!(text.starts_with("Keyboard Shortcuts\nSet: Studio\n\nTools\n"), "{text}"); + assert_eq!(*text, export_text("Studio", &Overrides::new())); + } + /// The dialog's Import… leaves an action its function key when the imported set gives the key /// to a command, through both Cancel and OK. #[test] diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index 65e7f5762..326b2b2c0 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -405,6 +405,38 @@ fn keyboard_shortcuts_import_and_export_ask_off_the_ui_thread() { assert_eq!(*written.borrow(), ["/keys/out.json", "/keys/command.json"]); } +/// The Keyboard Shortcuts dialog's Export Text… asks off the UI thread for a `.txt` file and writes +/// the dialog's working set, as edited so far, as text where the dialog answers. +#[test] +fn keyboard_shortcuts_export_text_writes_the_working_set_as_text() { + let (mut app, shown, _) = app(); + let texts: Rc>> = Default::default(); + let t = texts.clone(); + app.services.write = Some(Box::new(move |path: &str, b: &[u8]| { + t.borrow_mut().push((path.to_string(), String::from_utf8_lossy(b).into_owned())); + Ok(()) + })); + let ov = crate::keymap::overrides_from(&[("view.grid", "Ctrl+Alt+G")]); + app.run("edit.keyboardShortcuts", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().expect("Keyboard Shortcuts"); + d.fields.insert("overrides".into(), crate::keymap::stored::to_value(&ov)); + d.fields.insert("set".into(), json!("Custom")); + click(&mut app, "Export Text…", dialogs); + match shown.requests().as_slice() { + [PickRequest::Save(pick)] => assert_eq!(pick.filters, FilePick::filters([("Text", &["txt"][..])])), + other => panic!("{other:?}"), + } + shown.answer(&["/keys/list.txt"]); + poll(&mut app); + let texts = texts.borrow(); + let [(path, text)] = texts.as_slice() else { panic!("{texts:?}") }; + assert_eq!(path, "/keys/list.txt"); + assert_eq!(*text, crate::shortcut_editor::export_text("Custom", &ov)); + assert!(text.contains(&format!("\t{}\n", crate::menus::pretty_shortcut("Ctrl+Alt+G"))), "{text}"); + assert!(app.ui.shortcut_overrides.is_empty(), "the working set is kept only on OK"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("shortcuts"), "the dialog stays open"); +} + /// Keyboard Shortcuts' Import… opens a shortcut set another app saved as text (`.kys`) in Import /// Shortcut Set over the dialog when its file dialog answers, and the dialog's working set stays in /// Keyboard Shortcuts, which Cancel shows again. diff --git a/crates/ui-egui/src/tests/saveext.rs b/crates/ui-egui/src/tests/saveext.rs index 7a2fa718b..497ac0c74 100644 --- a/crates/ui-egui/src/tests/saveext.rs +++ b/crates/ui-egui/src/tests/saveext.rs @@ -88,6 +88,8 @@ fn save_panels_show_the_file_type_they_write() { assert_eq!(last_shown(&shown), FilePick::filters([("PostScript", &["ps"][..])]), "Print to a PostScript file"); app.run("shortcuts.export", json!({})).unwrap(); assert_eq!(last_shown(&shown), FilePick::filters([("Shortcut sets", &["json"][..])]), "Keyboard Shortcuts › Export…"); + app.run("shortcuts.exportText", json!({})).unwrap(); + assert_eq!(last_shown(&shown), FilePick::filters([("Text", &["txt"][..])]), "Keyboard Shortcuts › Export Text…"); let presets = [ ("pdf.preset.save", json!({"name": "Mine"}), "pdf.preset.export", "PDF presets", pdfcmds::PRESET_EXTS), ("print.presets.save", json!({"name": "Mine"}), "print.presets.export", "Print presets", printpresets::PRESET_EXTS), diff --git a/docs/mcp.md b/docs/mcp.md index c15e2f6ff..bd36412db 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3401,6 +3401,20 @@ They list such a command while the user has changed its shortcut (`overridden: t shortcut for that command runs what a click on the item runs: Object › Path › Clean Up…, Print Ink Fixes… and Edit › Find and Replace… open their dialogs. +`shortcuts.exportText {path?}` saves the current set's shortcut list as a UTF-8 text file, as Edit › Keyboard +Shortcuts › Export Text… does with the dialog's set as edited so far. The file starts with a "Keyboard Shortcuts" line +and a "Set:" line with the set's name, followed by a Tools section and a Menu Commands section. Each section lists the +rows Edit › Keyboard Shortcuts lists, in its order and with names as it shows them: one line per row, with the name, a +tab and the shortcuts as the dialog shows them on the current platform. A row with no shortcut ends after the tab. +Without `path` a save dialog asks for the file. A `path` that is not a string, or a file that cannot be written, +returns an error, and nothing is written. The layout is VectorCraft's own. The reference app's [Export keyboard +shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) +page describes saving the list from the Keyboard Shortcuts dialog as a text file, and does not describe its layout. + +```json +{"name":"run_command","arguments":{"command":"shortcuts.exportText","params":{"path":"/Users/me/Shortcuts.txt"}}} +``` + The desktop app adds its active artboard, the one the Artboards panel highlights, to the `object.align`, `object.distribute`, `object.distributeSpacing` and `select.allOnArtboard` calls it runs without `artboard`, as it does for `edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack`, and to each `command.batch`. A batch passes its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e6431761..0ff5c0e3d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -765,6 +765,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Cmd/Ctrl+F6 (`window.nextDocument`) and Cmd/Ctrl+Shift+F6 (`window.previousDocument`) make the next and the previous open document the current one, in tab order and as a click on its tab does, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for navigating to the next and the previous document. After the last document comes the first, and before the first document comes the last. From the Home screen the keys go to the first and the last document. Keyboard Shortcuts lists both under Window. - Cmd/Ctrl+Alt+Shift+U (`document.switchUnits`) switches the document to the next unit of measurement, in one undo step, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for switching units of measurement. The page does not give an order. VectorCraft steps through the units in the order a right-click on a ruler lists them (Points, Picas, Inches, Millimeters, Centimeters, Pixels, Feet & Inches, Meters, Yards, Feet) and goes from Feet back to Points. - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). + - Edit › Keyboard Shortcuts › Export Text… saves the set shown in the dialog as a UTF-8 text file, as the reference app's [Export keyboard shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) page describes (`shortcuts.exportText {path?}`). The layout is VectorCraft's own: the set's name, then the Tools and Menu Commands rows in the dialog's order, each with its name, a tab and its shortcuts as the dialog shows them, and nothing after the tab for a row with no shortcut. Tests `shortcut_editor::tests::export_text_*` and `tests::picks::keyboard_shortcuts_export_text_writes_the_working_set_as_text`. - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559).