diff --git a/Cargo.lock b/Cargo.lock
index 0c34a6a952..10c93a8322 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6666,6 +6666,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
+ "siphasher",
"skrifa 0.47.0",
"smallvec",
"thiserror 2.0.21",
diff --git a/ROADMAP.md b/ROADMAP.md
index b5a2f43c7a..fe325ff849 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -141,6 +141,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged.
- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged.
- 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
+- 2026-11-13: Gradient panel › Method: Hue Clockwise and Hue Counterclockwise mix a linear, radial, conic or diamond gradient's colors around the hue wheel in one direction, so red to blue passes through magenta one way and through yellow and green the other (`paint.editGradient` `method`: `hue-clockwise`, `hue-counterclockwise`); SVG and PDF exports write such a gradient as an image.
- 2026-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
- 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`).
- 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged.
diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs
index 0d4deca36f..b66c15070f 100644
--- a/apps/vectorcraft-cli/src/main.rs
+++ b/apps/vectorcraft-cli/src/main.rs
@@ -418,10 +418,18 @@ fn convert(args: &[String]) -> Result<(), String> {
// Save as Template writes every artboard, with the .ai options.
("file.saveAsTemplate", json!({"path": output, "format": "ait", "outlineText": outline_text, "acknowledgeLoss": replace_lossy}))
} else {
- // A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
- // PSD).
- let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
+ let params = json!({"path": output, "artboard": artboard, "range": range, "acknowledgeLoss": replace_lossy});
let mut params = with_pixel_scale(params, &output, scale, ppi);
+ // An option the format doesn't read comes back in the warnings, so each goes out only
+ // when it was asked for: `outlineText` with --outline-text, and `hiddenLayers` to the
+ // formats that have it (SVG, PSD), where a conversion keeps hidden layers and objects,
+ // written hidden.
+ if outline_text {
+ params["outlineText"] = json!(true);
+ }
+ if vectorcraft_engine::cmd::fileio::writable_format(None, Some(&output)).is_ok_and(|f| f.options.iter().any(|o| o.name == "hiddenLayers")) {
+ params["hiddenLayers"] = json!(true);
+ }
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
diff --git a/apps/vectorcraft-cli/tests/it/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs
index 71044be4a5..cdb16d28d3 100644
--- a/apps/vectorcraft-cli/tests/it/fileio.rs
+++ b/apps/vectorcraft-cli/tests/it/fileio.rs
@@ -177,6 +177,27 @@ fn convert_reports_import_warnings() {
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
}
+/// A conversion sends each format only the options it reads, so its warnings name an unused
+/// option only when the command line gave one: `--outline-text` for a format without it.
+#[test]
+fn convert_warns_about_an_unused_option_only_when_one_was_given() {
+ let svg = tmp("unused-option.svg");
+ std::fs::write(&svg, r#" "#).unwrap();
+ let unused = |out: &Value| -> Vec {
+ let w = out["warnings"].as_array().unwrap_or_else(|| panic!("{out}"));
+ w.iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
+ };
+ for ext in ["png", "jpg", "pdf", "ai", "eps", "psd", "dxf", "svg", "txt", "vectorcraft", "vctemplate"] {
+ let path = tmp(&format!("unused-option.{ext}"));
+ let out: Value = serde_json::from_str(ok(&["convert", &svg, &path]).trim()).unwrap();
+ assert!(unused(&out).is_empty(), "{ext}: {out}");
+ }
+ let out: Value = serde_json::from_str(ok(&["convert", &svg, &tmp("unused-option-outlined.svg"), "--outline-text"]).trim()).unwrap();
+ assert!(unused(&out).is_empty(), "{out}");
+ let out: Value = serde_json::from_str(ok(&["convert", &svg, &tmp("unused-option-outlined.png"), "--outline-text"]).trim()).unwrap();
+ assert_eq!(unused(&out), ["`outlineText` was not used: PNG files don't read it"], "{out}");
+}
+
#[test]
fn convert_writes_legacy_ai_with_its_options() {
// Two artboards, a shown rectangle and a hidden one.
diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml
index 55b9e344e7..1fab9ac36a 100644
--- a/apps/vectorcraft/Cargo.toml
+++ b/apps/vectorcraft/Cargo.toml
@@ -107,15 +107,16 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
# through winit and rfd.
wayland-backend = { version = "0.3", features = ["client_system"] }
-# Waits for the screen color sampler's portal answer and for the portal leaving the bus at the same
-# time (src/linux_screen_color/request.rs). Apache-2.0 OR MIT, already in the build through zbus.
-futures-lite = "2"
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
# exactly: review the diff before bumping.
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
zbus = "=5.19.0"
+# Waits for a portal answer (the save dialog's and the screen color sampler's) and for the portal
+# leaving the bus at the same time (src/portal_request.rs). Apache-2.0 OR MIT, already in the build
+# through zbus.
+futures-lite = "2"
# Tells whether arboard reads through the compositor's data-control protocol or through X11, the
# same check arboard makes (src/clipboard.rs). MIT OR Apache-2.0, already in the build through
# arboard.
diff --git a/apps/vectorcraft/src/linux_screen_color.rs b/apps/vectorcraft/src/linux_screen_color.rs
index 0f46af19d8..cfb8d7d69f 100644
--- a/apps/vectorcraft/src/linux_screen_color.rs
+++ b/apps/vectorcraft/src/linux_screen_color.rs
@@ -4,7 +4,6 @@
//! parent is the window, named on that thread as for the portal's save dialog (`portal_save`).
mod portal;
-mod request;
/// The portal's color sampler over the window `parent` holds; `ui` is woken when it answers.
pub fn sampler(ui: egui::Context, parent: super::host_services::Parent) -> vectorcraft_ui_egui::screen_color::StartSampler {
diff --git a/apps/vectorcraft/src/linux_screen_color/portal.rs b/apps/vectorcraft/src/linux_screen_color/portal.rs
index b7518e1ce3..21bcb34da5 100644
--- a/apps/vectorcraft/src/linux_screen_color/portal.rs
+++ b/apps/vectorcraft/src/linux_screen_color/portal.rs
@@ -61,9 +61,9 @@ pub(super) fn connect(builder: zbus::Result Result, Failure> {
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
- let token = super::request::token("vectorcraft_color").map_err(Failure::Failed)?;
+ let token = crate::portal_request::token("vectorcraft_color").map_err(Failure::Failed)?;
// Made before the call: a portal can send its answer before the call returns.
- let replies = super::request::Responses::new(conn).map_err(Failure::Failed)?;
+ let replies = crate::portal_request::Responses::new(conn).map_err(Failure::Failed)?;
let proxy = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot")
.map_err(|e| Failure::Failed(e.to_string()))?;
let options = HashMap::from([("handle_token", Value::from(token.as_str()))]);
@@ -106,6 +106,7 @@ fn answer(code: u32, results: &HashMap) -> Result HashMap {
HashMap::from([("color".into(), OwnedValue::try_from(Value::from(rgb)).unwrap())])
@@ -186,35 +187,6 @@ mod tests {
}
}
- /// A session bus of its own (`dbus-daemon`), `None` when there is none to run.
- fn private_bus() -> Option<(Daemon, String)> {
- use std::io::BufRead as _;
- let spawning = crate::spawning();
- let daemon = std::process::Command::new("dbus-daemon")
- .args(["--session", "--nofork", "--print-address=1"])
- .stdout(std::process::Stdio::piped())
- .spawn();
- drop(spawning);
- let Ok(daemon) = daemon else {
- eprintln!("no dbus-daemon: skipped");
- return None;
- };
- let mut daemon = Daemon(daemon);
- let mut address = String::new();
- std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap();
- Some((daemon, address.trim().to_string()))
- }
-
- /// Stops the private bus when the test ends, unwinding included.
- struct Daemon(std::process::Child);
-
- impl Drop for Daemon {
- fn drop(&mut self) {
- let _ = self.0.kill();
- let _ = self.0.wait();
- }
- }
-
/// The client's connection to the private bus at `address`, made as the sampler makes its own.
fn client(address: &str) -> Connection {
connect(zbus::blocking::connection::Builder::address(address)).unwrap()
diff --git a/apps/vectorcraft/src/linux_screen_color/request.rs b/apps/vectorcraft/src/linux_screen_color/request.rs
deleted file mode 100644
index f442b840a3..0000000000
--- a/apps/vectorcraft/src/linux_screen_color/request.rs
+++ /dev/null
@@ -1,127 +0,0 @@
-//! The answer to the color sampler's XDG desktop portal request
-//! ().
-//! The wait ends with the request's `Response`, or with an error when the portal leaves the bus
-//! first.
-
-use std::io::Read as _;
-
-use futures_lite::{StreamExt as _, future};
-use zbus::blocking::{Connection, MessageIterator};
-use zbus::zvariant::OwnedObjectPath;
-
-const DESKTOP: &str = "org.freedesktop.portal.Desktop";
-
-/// A request token: `prefix`, then 128 bits from the operating system's random device, so the
-/// token is unique and can't be guessed, as the Request documentation asks.
-pub(super) fn token(prefix: &str) -> Result {
- let mut bytes = [0; 16];
- std::fs::File::open("/dev/urandom")
- .and_then(|mut file| file.read_exact(&mut bytes))
- .map_err(|e| format!("could not create a portal request token: {e}"))?;
- let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
- Ok(format!("{prefix}_{random}"))
-}
-
-/// Subscriptions made before a portal method call, on every request path: a portal older than
-/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer
-/// can come before the call's reply. Each stream keeps every signal from the time it is made.
-pub(super) struct Responses {
- replies: MessageIterator,
- owners: MessageIterator,
-}
-
-impl Responses {
- pub(super) fn new(conn: &Connection) -> Result {
- let reply_rule = zbus::MatchRule::builder()
- .msg_type(zbus::message::Type::Signal)
- .sender(DESKTOP)
- .and_then(|b| b.interface("org.freedesktop.portal.Request"))
- .and_then(|b| b.member("Response"))
- .map_err(|e| e.to_string())?
- .build();
- let owner_rule = zbus::MatchRule::builder()
- .msg_type(zbus::message::Type::Signal)
- .sender("org.freedesktop.DBus")
- .and_then(|b| b.path("/org/freedesktop/DBus"))
- .and_then(|b| b.interface("org.freedesktop.DBus"))
- .and_then(|b| b.member("NameOwnerChanged"))
- .and_then(|b| b.arg(0, DESKTOP))
- .map_err(|e| e.to_string())?
- .build();
- Ok(Self {
- replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?,
- owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?,
- })
- }
-
- /// The `Response` on the request path `call` returned, from the connection that returned it.
- /// An error when that connection leaves the bus first, or the bus connection closes.
- pub(super) fn wait(self, call: &zbus::Message) -> Result {
- enum Event {
- Reply(Option>),
- Owner(Option>),
- }
- let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
- let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?;
- let mut replies = self.replies.into_inner();
- let mut owners = self.owners.into_inner();
- future::block_on(async {
- loop {
- let event = future::or(async { Event::Reply(replies.next().await) }, async { Event::Owner(owners.next().await) }).await;
- match event {
- Event::Reply(reply) => {
- let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?;
- let header = reply.header();
- if header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str())
- {
- return Ok(reply);
- }
- }
- Event::Owner(message) => {
- let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?;
- if owner_disappeared(&message, &portal)? {
- return Err("the desktop portal disappeared while waiting for its answer".into());
- }
- }
- }
- }
- })
- }
-}
-
-/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer
-/// owns the portal's name. The bus itself must send it: another connection can't end the wait.
-fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result {
- if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") {
- return Ok(false);
- }
- let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?;
- Ok(name == DESKTOP && old == portal && new != portal)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn only_the_bus_can_report_portal_owner_loss() {
- let changed = |sender, name, old, new| {
- zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged")
- .unwrap()
- .sender(sender)
- .unwrap()
- .build(&(name, old, new))
- .unwrap()
- };
- let forged = changed(":1.42", DESKTOP, ":1.7", "");
- assert!(!owner_disappeared(&forged, ":1.7").unwrap());
- let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", "");
- assert!(owner_disappeared(&real, ":1.7").unwrap());
- let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", "");
- assert!(!owner_disappeared(&other, ":1.7").unwrap());
- let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", "");
- assert!(!owner_disappeared(&old, ":1.7").unwrap());
- let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7");
- assert!(!owner_disappeared(&acquired, ":1.7").unwrap());
- }
-}
diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs
index 33e962c0aa..cd3ea7cd36 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -53,6 +53,8 @@ mod mac_tablet;
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
+#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
+mod portal_request;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
mod portal_save;
mod prefs_dir;
diff --git a/apps/vectorcraft/src/portal_request.rs b/apps/vectorcraft/src/portal_request.rs
new file mode 100644
index 0000000000..da4cb9ad23
--- /dev/null
+++ b/apps/vectorcraft/src/portal_request.rs
@@ -0,0 +1,231 @@
+//! Linux and the BSDs: the answer to an XDG desktop portal request
+//! (),
+//! for the save dialog (`portal_save`) and, on Linux, the screen color sampler
+//! (`linux_screen_color`). The wait ends with the request's `Response`, or with an error when the
+//! portal leaves the bus first.
+
+use futures_lite::{StreamExt as _, future};
+use zbus::blocking::{Connection, MessageIterator};
+use zbus::zvariant::OwnedObjectPath;
+
+const DESKTOP: &str = "org.freedesktop.portal.Desktop";
+
+/// A request token: `prefix`, then 128 bits from the operating system's random device, so the
+/// token is unique and can't be guessed, as the Request documentation asks.
+#[cfg(target_os = "linux")]
+pub(crate) fn token(prefix: &str) -> Result {
+ use std::io::Read as _;
+
+ let mut bytes = [0; 16];
+ std::fs::File::open("/dev/urandom")
+ .and_then(|mut file| file.read_exact(&mut bytes))
+ .map_err(|e| format!("could not create a portal request token: {e}"))?;
+ let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
+ Ok(format!("{prefix}_{random}"))
+}
+
+/// Subscriptions made before a portal method call, on every request path: a portal older than
+/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer
+/// can come before the call's reply. Each stream keeps every signal from the time it is made.
+pub(crate) struct Responses {
+ replies: MessageIterator,
+ owners: MessageIterator,
+}
+
+impl Responses {
+ pub(crate) fn new(conn: &Connection) -> Result {
+ let reply_rule = zbus::MatchRule::builder()
+ .msg_type(zbus::message::Type::Signal)
+ .sender(DESKTOP)
+ .and_then(|b| b.interface("org.freedesktop.portal.Request"))
+ .and_then(|b| b.member("Response"))
+ .map_err(|e| e.to_string())?
+ .build();
+ let owner_rule = zbus::MatchRule::builder()
+ .msg_type(zbus::message::Type::Signal)
+ .sender("org.freedesktop.DBus")
+ .and_then(|b| b.path("/org/freedesktop/DBus"))
+ .and_then(|b| b.interface("org.freedesktop.DBus"))
+ .and_then(|b| b.member("NameOwnerChanged"))
+ .and_then(|b| b.arg(0, DESKTOP))
+ .map_err(|e| e.to_string())?
+ .build();
+ Ok(Self {
+ replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?,
+ owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?,
+ })
+ }
+
+ /// The `Response` on the request path `call` returned, from the connection that returned it.
+ /// An error when that connection leaves the bus first, or the bus connection closes. A
+ /// `Response` the portal sent before it left is still the answer.
+ pub(crate) fn wait(self, call: &zbus::Message) -> Result {
+ enum Event {
+ Reply(Option>),
+ Owner(Option>),
+ }
+ let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
+ let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?;
+ let answers = |reply: &zbus::Message| {
+ let header = reply.header();
+ header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str())
+ };
+ let mut replies = self.replies.into_inner();
+ let mut owners = self.owners.into_inner();
+ future::block_on(async {
+ loop {
+ let event = future::or(async { Event::Owner(owners.next().await) }, async { Event::Reply(replies.next().await) }).await;
+ match event {
+ Event::Reply(reply) => {
+ let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?;
+ if answers(&reply) {
+ return Ok(reply);
+ }
+ }
+ Event::Owner(message) => {
+ let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?;
+ if owner_disappeared(&message, &portal)? {
+ // zbus puts each message in every matching stream before it reads the
+ // next one, so a `Response` that came before the bus's report is
+ // already queued: it is still the answer.
+ while let Some(Some(reply)) = future::poll_once(replies.next()).await {
+ if let Ok(reply) = reply
+ && answers(&reply)
+ {
+ return Ok(reply);
+ }
+ }
+ return Err("the desktop portal disappeared while waiting for its answer".into());
+ }
+ }
+ }
+ }
+ })
+ }
+}
+
+/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer
+/// owns the portal's name. The bus itself must send it: another connection can't end the wait.
+fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result {
+ if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") {
+ return Ok(false);
+ }
+ let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?;
+ Ok(name == DESKTOP && old == portal && new != portal)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn only_the_bus_can_report_portal_owner_loss() {
+ let changed = |sender, name, old, new| {
+ zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged")
+ .unwrap()
+ .sender(sender)
+ .unwrap()
+ .build(&(name, old, new))
+ .unwrap()
+ };
+ let forged = changed(":1.42", DESKTOP, ":1.7", "");
+ assert!(!owner_disappeared(&forged, ":1.7").unwrap());
+ let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", "");
+ assert!(owner_disappeared(&real, ":1.7").unwrap());
+ let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", "");
+ assert!(!owner_disappeared(&other, ":1.7").unwrap());
+ let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", "");
+ assert!(!owner_disappeared(&old, ":1.7").unwrap());
+ let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7");
+ assert!(!owner_disappeared(&acquired, ":1.7").unwrap());
+ }
+
+ /// Through a real D-Bus: when the bus has reported the portal gone, a `Response` the portal sent
+ /// before it left is still the answer; without one the wait ends with an error.
+ #[test]
+ fn a_queued_answer_wins_over_the_portal_leaving() {
+ use std::collections::HashMap;
+
+ use zbus::blocking::connection::Builder;
+ use zbus::zvariant::OwnedValue;
+
+ for answered in [true, false] {
+ let Some((_daemon, address)) = test_bus::private_bus() else { return };
+ let address = address.as_str();
+ let portal = Builder::address(address).unwrap().name(DESKTOP).unwrap().build().unwrap();
+ let unique = portal.unique_name().unwrap().to_string();
+ let client = Builder::address(address).unwrap().build().unwrap();
+ let responses = Responses::new(&client).unwrap();
+ // zbus puts each message in every matching stream before it reads the next one
+ // (`src/connection/socket_reader.rs`), so once this stream has the bus's report,
+ // `responses` holds the `Response` sent before it.
+ let gone_rule = zbus::MatchRule::builder()
+ .msg_type(zbus::message::Type::Signal)
+ .sender("org.freedesktop.DBus")
+ .unwrap()
+ .member("NameOwnerChanged")
+ .unwrap()
+ .arg(0, DESKTOP)
+ .unwrap()
+ .build();
+ let gone = MessageIterator::for_match_rule(gone_rule, &client, None).unwrap();
+ let path = "/org/freedesktop/portal/desktop/request/test/t1";
+ if answered {
+ let results = HashMap::::new();
+ portal.emit_signal(None::<&str>, path, "org.freedesktop.portal.Request", "Response", &(0_u32, results)).unwrap();
+ }
+ portal.close().unwrap();
+ // On a thread of its own, so a report that never comes fails the test instead of
+ // hanging it.
+ let (seen, report) = std::sync::mpsc::channel();
+ let left = unique.clone();
+ std::thread::spawn(move || {
+ let mut gone = gone;
+ let _ = seen.send(gone.any(|m| m.is_ok_and(|m| owner_disappeared(&m, &left).unwrap_or(false))));
+ });
+ assert_eq!(report.recv_timeout(std::time::Duration::from_secs(30)), Ok(true), "the bus did not report the portal gone");
+ // The call's reply as `wait` reads it: the request path, from the portal's connection.
+ let call = zbus::Message::signal("/", "org.example.Test", "Reply")
+ .unwrap()
+ .sender(unique.as_str())
+ .unwrap()
+ .build(&OwnedObjectPath::try_from(path).unwrap())
+ .unwrap();
+ let waited = responses.wait(&call);
+ assert_eq!(waited.is_ok(), answered, "{waited:?}");
+ }
+ }
+}
+
+/// The private session bus that the D-Bus tests of the portal requests run on.
+#[cfg(test)]
+pub(crate) mod test_bus {
+ /// A session bus of its own (`dbus-daemon`), `None` when there is none to run.
+ pub(crate) fn private_bus() -> Option<(Daemon, String)> {
+ use std::io::BufRead as _;
+ let spawning = crate::spawning();
+ let daemon = std::process::Command::new("dbus-daemon")
+ .args(["--session", "--nofork", "--print-address=1"])
+ .stdout(std::process::Stdio::piped())
+ .spawn();
+ drop(spawning);
+ let Ok(daemon) = daemon else {
+ eprintln!("no dbus-daemon: skipped");
+ return None;
+ };
+ let mut daemon = Daemon(daemon);
+ let mut address = String::new();
+ std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap();
+ Some((daemon, address.trim().to_string()))
+ }
+
+ /// Stops the private bus when the test ends, unwinding included.
+ pub(crate) struct Daemon(std::process::Child);
+
+ impl Drop for Daemon {
+ fn drop(&mut self) {
+ let _ = self.0.kill();
+ let _ = self.0.wait();
+ }
+ }
+}
diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs
index a9bf5d1009..6d1a8734b2 100644
--- a/apps/vectorcraft/src/portal_save.rs
+++ b/apps/vectorcraft/src/portal_save.rs
@@ -8,6 +8,7 @@
//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension.
//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its
//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call.
+//! rfd also shows the dialog when the portal leaves the bus before it answers.
//!
//! The dialog's parent is the window: `x11:` on X11, and on Wayland (Linux) a handle
//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`).
@@ -183,37 +184,18 @@ pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option Result, String> {
use std::sync::atomic::{AtomicU32, Ordering};
- use zbus::blocking::{MessageIterator, Proxy};
- use zbus::zvariant::OwnedObjectPath;
+ use zbus::blocking::Proxy;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
- const REQUEST: &str = "org.freedesktop.portal.Request";
static NEXT: AtomicU32 = AtomicU32::new(0);
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
- // Listen before asking, on every request path: a portal older than version 0.9 ignores
- // `handle_token` and answers on the request path it returns, and its answer can come before
- // the call's reply. The stream keeps every `Response` from the time it is made.
- let rule = zbus::MatchRule::builder()
- .msg_type(zbus::message::Type::Signal)
- .sender(DESKTOP)
- .and_then(|b| b.interface(REQUEST))
- .and_then(|b| b.member("Response"))
- .map_err(|e| e.to_string())?
- .build();
- let responses = MessageIterator::for_match_rule(rule, conn, None).map_err(|e| e.to_string())?;
+ // Made before the call: a portal can send its answer before the call returns, and a portal
+ // that leaves the bus while its dialog is open ends the wait.
+ let responses = crate::portal_request::Responses::new(conn)?;
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
// The title is empty, as rfd sends it for a save.
let call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
- let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
- // The answer is the `Response` on the path returned, from the connection that returned it.
- let portal = call.header().sender().map(|s| s.as_str().to_string());
- let reply = responses
- .filter_map(Result::ok)
- .find(|m| {
- let header = m.header();
- header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref()
- })
- .ok_or("the save dialog closed without an answer")?;
+ let reply = responses.wait(&call)?;
let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
@@ -495,26 +477,22 @@ mod tests {
Old,
/// As `Old`, with the `Response` sent before the reply that names its path.
OldEarly,
+ /// As `Current`, but it leaves the bus 300 ms after its reply without a `Response`, as a
+ /// portal that exits while its dialog is open.
+ Exits,
+ /// Leaves the bus right after its reply, without a `Response`.
+ ExitsAtOnce,
+ /// Sends its `Response` right after its reply, then leaves the bus.
+ AnswersAndExits,
}
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it.
/// `None` when there is no `dbus-daemon` to run.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
- fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
- use std::io::BufRead as _;
-
+ fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, crate::portal_request::test_bus::Daemon)> {
use zbus::zvariant::OwnedObjectPath;
- let spawning = crate::spawning();
- let daemon = std::process::Command::new("dbus-daemon")
- .args(["--session", "--nofork", "--print-address=1"])
- .stdout(std::process::Stdio::piped())
- .spawn();
- drop(spawning);
- let mut daemon = daemon.ok()?;
- let mut address = String::new();
- std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
- let address = address.trim().to_string();
+ let (daemon, address) = crate::portal_request::test_bus::private_bus()?;
let server =
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
let seen = Seen::default();
@@ -539,8 +517,8 @@ mod tests {
});
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
let token = match portal {
- Portal::Current => text("handle_token").unwrap_or_default(),
Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()),
+ Portal::Current | Portal::Exits | Portal::ExitsAtOnce | Portal::AnswersAndExits => text("handle_token").unwrap_or_default(),
};
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
@@ -558,14 +536,22 @@ mod tests {
if server.reply(&header, &handle).is_err() {
break;
}
- if portal == Portal::Old {
+ if matches!(portal, Portal::Old | Portal::Exits) {
// As a person answers the dialog: after the caller has the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
+ if matches!(portal, Portal::Exits | Portal::ExitsAtOnce) {
+ break;
+ }
if portal != Portal::OldEarly && respond().is_err() {
break;
}
+ if portal == Portal::AnswersAndExits {
+ break;
+ }
}
+ // The portal leaves the bus: the bus sends `NameOwnerChanged` for its name.
+ let _ = server.close();
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
Some((client, seen, daemon))
@@ -589,7 +575,7 @@ mod tests {
#[test]
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
- let Some((conn, seen, mut daemon)) = mock_portal(script, Portal::Current) else {
+ let Some((conn, seen, _daemon)) = mock_portal(script, Portal::Current) else {
eprintln!("no dbus-daemon: skipped");
return;
};
@@ -599,7 +585,6 @@ mod tests {
assert_eq!(save_within(&conn, &request), Ok(None));
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
- let _ = daemon.kill();
}
/// A portal older than 0.9 answers on the request path it returns, and the answer is awaited
@@ -607,13 +592,12 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
- let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
+ let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
- let _ = daemon.kill();
}
/// A portal older than 0.9 can answer before its reply names the request path: the answer is
@@ -622,14 +606,41 @@ mod tests {
#[test]
fn an_answer_that_comes_before_the_path_is_kept() {
let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)];
- let Some((conn, _seen, mut daemon)) = mock_portal(script, Portal::OldEarly) else {
+ let Some((conn, _seen, _daemon)) = mock_portal(script, Portal::OldEarly) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
assert_eq!(save_within(&conn, &request), Ok(None));
- let _ = daemon.kill();
+ }
+
+ /// A portal that leaves the bus after its reply without answering, while its dialog is open or
+ /// at once, ends the wait with an error, and rfd shows the dialog.
+ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
+ #[test]
+ fn a_portal_that_exits_during_the_dialog_ends_the_wait() {
+ for portal in [Portal::Exits, Portal::ExitsAtOnce] {
+ let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], portal) else {
+ eprintln!("no dbus-daemon: skipped");
+ return;
+ };
+ assert_eq!(save_within(&conn, &request()), Err("the desktop portal disappeared while waiting for its answer".to_string()));
+ }
+ }
+
+ /// A portal that answers and then leaves the bus: the save reads the answer. Whether the answer
+ /// or the bus's report comes first depends on timing here; `portal_request`'s
+ /// `a_queued_answer_wins_over_the_portal_leaving` queues both before the wait.
+ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
+ #[test]
+ fn an_answer_sent_just_before_the_portal_exits_is_kept() {
+ let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::AnswersAndExits) else {
+ eprintln!("no dbus-daemon: skipped");
+ return;
+ };
+ let request = request();
+ assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
}
/// The labels go to the dialog in the interface's language, which is how the answer is matched.
diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs
index 46dcdb52ba..c5e5bc5bea 100644
--- a/crates/color/src/cms.rs
+++ b/crates/color/src/cms.rs
@@ -531,7 +531,7 @@ impl Cms {
}
}
- /// Gamut warning: can this colour be printed in the working CMYK space?
+ /// Gamut warning: can this color be printed in this context's CMYK space?
pub fn out_of_gamut(&self, c: &Color) -> bool {
self.gamut_error(c) > GAMUT_THRESHOLD
}
diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs
index e49478eab7..54cb50c197 100644
--- a/crates/color/src/gradient.rs
+++ b/crates/color/src/gradient.rs
@@ -65,24 +65,62 @@ pub enum GradientMethod {
/// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to
/// yellow passes through a light blue-grey rather than a dull mid grey.
Perceptual,
+ /// Mixed around the hue wheel, clockwise as the color wheels draw it: the hue angle falls
+ /// from each stop to the next (red, magenta, blue, cyan, green, yellow, red), and saturation
+ /// and brightness change evenly ([`hue_mix`]).
+ HueClockwise,
+ /// Mixed around the hue wheel the other way: the hue angle rises from each stop to the next
+ /// (red, yellow, green, cyan, blue, magenta, red).
+ HueCounterclockwise,
}
impl GradientMethod {
- /// Parse a method name (`classic`, `perceptual`; any case).
+ /// Every method, in the order the Gradient panel lists them.
+ pub const ALL: [GradientMethod; 4] = [Self::Classic, Self::Perceptual, Self::HueClockwise, Self::HueCounterclockwise];
+ /// Parse a method name (`classic`, `perceptual`, `hue-clockwise`, `hue-counterclockwise`;
+ /// any case, and the hyphen may be a space or an underscore, or left out). Any other text,
+ /// one with a separator elsewhere included, is `None`.
pub fn parse(s: &str) -> Option {
- [Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
+ let name = s.to_ascii_lowercase();
+ Self::ALL.into_iter().find(|m| ["-", " ", "_", ""].iter().any(|sep| m.id().replace('-', sep) == name))
}
pub fn label(self) -> &'static str {
match self {
Self::Classic => "Classic",
Self::Perceptual => "Perceptual",
+ Self::HueClockwise => "Hue Clockwise",
+ Self::HueCounterclockwise => "Hue Counterclockwise",
+ }
+ }
+ /// The method's name in command params (`classic`, `perceptual`, `hue-clockwise`,
+ /// `hue-counterclockwise`).
+ pub fn id(self) -> &'static str {
+ match self {
+ Self::Classic => "classic",
+ Self::Perceptual => "perceptual",
+ Self::HueClockwise => "hue-clockwise",
+ Self::HueCounterclockwise => "hue-counterclockwise",
}
}
pub fn is_classic(&self) -> bool {
*self == Self::Classic
}
- /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an
- /// RGB colour between the two).
+ /// Does it mix around the hue wheel (Hue Clockwise or Hue Counterclockwise)? The colors
+ /// between two stops then are not mixes of the stops' inks.
+ pub fn is_hue(&self) -> bool {
+ matches!(self, Self::HueClockwise | Self::HueCounterclockwise)
+ }
+ /// The method that mixes `b` to `a` through the colors this one mixes `a` to `b` through: a
+ /// hue method turned the other way, Classic and Perceptual as they are.
+ pub fn mirrored(self) -> Self {
+ match self {
+ Self::HueClockwise => Self::HueCounterclockwise,
+ Self::HueCounterclockwise => Self::HueClockwise,
+ m => m,
+ }
+ }
+ /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), in OKLab (Perceptual: an
+ /// RGB colour between the two), or around the hue wheel ([`hue_mix`]: an RGB colour).
pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color {
match self {
Self::Classic => Color::lerp(a, b, t),
@@ -93,10 +131,68 @@ impl GradientMethod {
let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 };
Color::rgb(c(r), c(g), c(bl))
}
+ Self::HueClockwise => hue_mix(a, b, t, false),
+ Self::HueCounterclockwise => hue_mix(a, b, t, true),
}
}
}
+/// The brightness (0..1) at or below which a color is black for [`hue_mix`], and the spread
+/// between its largest and smallest RGB numbers (saturation × brightness) at or below which it
+/// has no hue of its own: under half a color level.
+const NO_HUE: f32 = 0.4 / 255.0;
+
+/// The hue difference (degrees) that moves an RGB number by half a color level in a color whose
+/// largest and smallest RGB numbers are 1 apart; a color with a smaller spread takes
+/// proportionally more degrees. [`hue_mix`] counts two hues closer than this as one hue.
+const SAME_HUE: f32 = 60.0 * 0.5 / 255.0;
+
+/// `a` and `b` mixed at `t` (0..1) around the hue wheel, as display RGB: hue (degrees),
+/// saturation and brightness each change evenly from `a`'s to `b`'s. The hue angle only rises
+/// (`rising`) or only falls, by the fixed-direction rules of
+/// , and two equal hues stay where they are.
+/// Two hues that differ by less than half a color level ([`SAME_HUE`]) count as equal and take the
+/// short way, so a color and a tint or shade of it, whose computed hues differ by rounding, do
+/// not turn the whole wheel. A color whose RGB numbers are less than half a color level apart
+/// ([`NO_HUE`]) has no hue and takes the other color's; black has no saturation either and takes
+/// the other color's too. Two colors without a hue mix as grays. A `t` that is not a number
+/// gives `a`.
+pub fn hue_mix(a: &Color, b: &Color, t: f32, rising: bool) -> Color {
+ let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
+ let ([mut h0, mut s0, v0], [mut h1, mut s1, v1]) = (a.to_hsb(), b.to_hsb());
+ let (black0, black1) = (v0 <= NO_HUE, v1 <= NO_HUE);
+ // The spread between each color's own largest and smallest RGB numbers.
+ let (spread0, spread1) = (s0 * v0, s1 * v1);
+ // Black takes the other color's saturation, so it darkens that color rather than graying it.
+ if black0 && !black1 {
+ s0 = s1;
+ } else if black1 && !black0 {
+ s1 = s0;
+ }
+ let (gray0, gray1) = (black0 || spread0 <= NO_HUE, black1 || spread1 <= NO_HUE);
+ match (gray0, gray1) {
+ (true, _) => h0 = h1,
+ (false, true) => h1 = h0,
+ (false, false) => {
+ // The short way from the first hue to the second, -180..180.
+ let short = (h1 - h0 + 180.0).rem_euclid(360.0) - 180.0;
+ h1 = if short.abs() * spread0.min(spread1) <= SAME_HUE {
+ h0 + short
+ } else if rising {
+ h0 + short.rem_euclid(360.0)
+ } else {
+ h0 + short.rem_euclid(360.0) - 360.0
+ };
+ }
+ }
+ let at = |p: f32, q: f32| p + (q - p) * t;
+ let (h, s, v) = (at(h0, h1), at(s0, s1), at(v0, v1));
+ if !(h.is_finite() && s.is_finite() && v.is_finite()) {
+ return Color::rgb(0.0, 0.0, 0.0);
+ }
+ Color::from_hsb(h.rem_euclid(360.0), s.clamp(0.0, 1.0), v.clamp(0.0, 1.0))
+}
+
/// What a linear, radial, conic or diamond gradient paints past the ends of its stops (the
/// Gradient panel's Spread). With Repeat and Reflect, the stops run [`Gradient::repeats`] times
/// along the vector (around the circle, for a conic), and keep going past both of its ends.
@@ -192,10 +288,11 @@ const MAX_INK_TINTS: usize = 4096;
const INK_MIX_STEPS: u32 = 64;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
-/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
-/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order.
-fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) {
- let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb();
+/// stays within [`PERCEPTUAL_TOLERANCE`] of `method`'s mix of `a` and `b` (Perceptual, or around
+/// the hue wheel): the span halved where a straight line strays, at most [`PERCEPTUAL_DEPTH`]
+/// times, in order.
+fn perceptual_steps(method: GradientMethod, a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) {
+ let at = |v: f32| method.mix(a, b, v).to_rgb();
let (c0, c1) = (at(v0), at(v1));
let stray = [0.25, 0.5, 0.75]
.into_iter()
@@ -206,19 +303,20 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out:
.fold(0.0, f32::max);
if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE {
let m = (v0 + v1) / 2.0;
- perceptual_steps(a, b, (v0, m), depth + 1, out);
+ perceptual_steps(method, a, b, (v0, m), depth + 1, out);
out.push(m);
- perceptual_steps(a, b, (m, v1), depth + 1, out);
+ perceptual_steps(method, a, b, (m, v1), depth + 1, out);
}
}
-/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a`
-/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
-fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec {
+/// The mix positions (`v`, 0..1) of the stops the curve of a gradient mixed by `method`
+/// (Perceptual, or around the hue wheel) has between stops `a` and `b`, in order:
+/// [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
+fn perceptual_mixes(method: GradientMethod, a: &GradientStop, b: &GradientStop) -> Vec {
let mut vs = Vec::new();
- perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs);
+ perceptual_steps(method, &a.color, &b.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
- perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs);
+ perceptual_steps(method, &a.color, &b.color, (0.5, 1.0), 1, &mut vs);
vs
}
@@ -512,10 +610,11 @@ impl Gradient {
};
(t, c, o, Some(s.midpoint), None)
});
- // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
- // midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
+ // Perceptual and the hue methods: stops along the mix (`v`) where a straight line would
+ // stray from it, the midpoint (v = ½) among them, placed through the midpoint as
+ // [`Self::sample`] maps them.
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
- perceptual_mixes(s, n).into_iter().map(move |v| {
+ perceptual_mixes(self.method, s, n).into_iter().map(move |v| {
let mid = v == 0.5;
let t = curve_offset(s, n, v);
let o = s.opacity + (n.opacity - s.opacity) * v;
@@ -571,11 +670,12 @@ impl Gradient {
/// position itself when the two colors' lightness differs by less than
/// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere
/// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even
- /// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more
- /// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
+ /// steps), as from red to blue. `None` for a Classic gradient, for one mixed around the hue
+ /// wheel (its colors between two stops are not mixes of the stops' inks), and when the curve
+ /// has more than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_mixes(&self) -> Option> {
use crate::libraries::srgb_to_oklab;
- if self.method.is_classic() || !self.fits_ink_tints() {
+ if self.method != GradientMethod::Perceptual || !self.fits_ink_tints() {
return None;
}
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
@@ -592,7 +692,7 @@ impl Gradient {
.collect::>()
.windows(2)
.all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0);
- for v in perceptual_mixes(s, n) {
+ for v in perceptual_mixes(self.method, s, n) {
let weight = if by_position {
v
} else {
@@ -650,8 +750,10 @@ impl Gradient {
Some(Ramp { stops })
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
- /// segment's midpoint mirrored with it.
+ /// segment's midpoint mirrored with it, and a hue method turned the other way (red to blue
+ /// through magenta, reversed, is blue to red through magenta).
pub fn reverse(&mut self) {
+ self.method = self.method.mirrored();
self.stops.reverse();
for i in 0..self.stops.len() {
// A midpoint belongs to the segment after its stop: reversed, stop i's segment is the
@@ -666,6 +768,13 @@ impl Gradient {
pub fn sort(&mut self) {
self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
}
+ /// The gradient's colors as a Classic gradient: its curve's stops ([`Self::expanded_stops`]) as
+ /// stops of their own, mixed in display RGB, within [`PERCEPTUAL_TOLERANCE`] of it. The stops
+ /// keep no swatch links, tints or process plates.
+ pub fn as_classic_curve(&self) -> Gradient {
+ let stops = self.expanded().map(|(t, color, opacity, _)| GradientStop { opacity, ..GradientStop::new(t, color) }).collect();
+ Gradient { kind: self.kind, stops, dither: self.dither, method: GradientMethod::Classic, spread: self.spread, repeats: self.repeats }
+ }
/// The part of the gradient from `from` to `to` (0..1, either way round) as a linear gradient
/// of its own running 0 → 1: the same colours (midpoints become stops, as in
/// [`Self::expanded_stops`]). One colour throughout when `from == to`.
@@ -1261,8 +1370,9 @@ impl GradientPaint {
}
/// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill
/// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a
- /// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix
- /// linearly between the stops, as SVG's and PDF's gradients do)?
+ /// dithered fill (dither applies to fills only), or a Perceptual one or one mixed around the
+ /// hue wheel (its colours don't mix linearly between the stops, as SVG's and PDF's gradients
+ /// do)?
pub fn draws_as_pixels(&self, fill: bool) -> bool {
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic()
}
diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs
index 8f9933c40c..fa111586ba 100644
--- a/crates/color/src/gradient/spread.rs
+++ b/crates/color/src/gradient/spread.rs
@@ -59,6 +59,9 @@ impl Gradient {
if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() {
return None;
}
+ if self.mirrors_a_hue_turn() {
+ return self.as_classic_curve().padded_within(geom, area, max_stops);
+ }
let period = geom.period(kind, self.repeat_count());
let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)];
let ts = corners.map(|p| period.param_at(kind, p));
@@ -128,6 +131,9 @@ impl Gradient {
if self.stops.is_empty() {
return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() });
}
+ if self.mirrors_a_hue_turn() {
+ return Cow::Owned(self.as_classic_curve().unrolled_within(max_stops).into_owned());
+ }
let n = self.repeat_count() as usize;
let budget = max_stops.min(MAX_SPREAD_STOPS);
let kept = n.min(budget / 2).max(1);
@@ -159,12 +165,22 @@ impl Gradient {
if !self.is_spread() || self.stops.is_empty() {
return None;
}
+ if self.mirrors_a_hue_turn() {
+ return self.as_classic_curve().cycle();
+ }
let one = whole_run(&self.stops);
let runs = if self.spread == GradientSpread::Reflect { 2 } else { 1 };
let stops = self.runs(&one, false, 0.0, runs, runs as f64);
Some((Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }, runs as u32))
}
+ /// Does the gradient reflect while mixing around the hue wheel? A mirrored run of its stops
+ /// would turn the wheel the other way, and one gradient has one method, so its runs are
+ /// written from its curve's stops ([`Self::as_classic_curve`]), which mirror as they are.
+ fn mirrors_a_hue_turn(&self) -> bool {
+ self.method.is_hue() && self.spread == GradientSpread::Reflect
+ }
+
/// `n` runs of `one` (one run of the stops, [`whole_run`]) on a line of `of` runs from 0 to 1,
/// the first of them `start` runs along it and an odd-numbered run when `first_odd` (with
/// Reflect, the odd-numbered runs are mirrored).
diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs
index a88319ccaa..ab088293da 100644
--- a/crates/color/src/gradient/spread/tests.rs
+++ b/crates/color/src/gradient/spread/tests.rs
@@ -118,6 +118,46 @@ fn reflect_mirrors_every_other_run_and_its_midpoints() {
assert!((p.gradient.stops[2].midpoint - 0.7).abs() < 1e-6, "{:?}", p.gradient.stops);
}
+/// A gradient mixed around the hue wheel that reflects: a mirrored run turns the wheel back the
+/// way it came, so every point keeps its color when the runs are written out as a gradient that
+/// pads, unrolled along the vector, or as one cycle, with a repeating one's too.
+#[test]
+fn reflected_and_repeated_hue_turns_keep_every_color() {
+ let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None };
+ let stops = || {
+ let mut red = GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0));
+ red.midpoint = 0.3;
+ vec![red, GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]
+ };
+ let near = |a: Color, b: Color, what: &str| {
+ assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}");
+ };
+ for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
+ for spread in [GradientSpread::Reflect, GradientSpread::Repeat] {
+ let g = Gradient { method, spread, repeats: 2, ..Gradient::new(GradientKind::Linear, stops()) };
+ let p = g.padded_over(&geom, Rect::new(-10.0, 0.0, 20.0, 10.0)).unwrap();
+ assert_eq!((p.gradient.spread, p.gradient.repeats), (GradientSpread::Pad, 1));
+ for k in 0..300 {
+ let q = Point::new(-10.0 + f64::from(k) * 0.1 + 0.013, 5.0);
+ let want = g.sample(g.spread_param(geom.param_at(GradientKind::Linear, q)) as f32).0;
+ let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0;
+ near(want, got, &format!("{method:?} {spread:?} padded at {}", q.x));
+ }
+ let unrolled = g.unrolled();
+ let (cycle, runs) = g.cycle().unwrap();
+ for k in 0..=200 {
+ let t = (k as f32 + 0.37) / 201.0;
+ let want = g.sample(g.spread_param(f64::from(t)) as f32).0;
+ near(want, unrolled.sample(t).0, &format!("{method:?} {spread:?} unrolled at {t}"));
+ // One cycle covers `runs` of the two runs between 0 and 1.
+ let along = t * runs as f32 / 2.0;
+ let want = g.sample(g.spread_param(f64::from(along)) as f32).0;
+ near(want, cycle.sample(t).0, &format!("{method:?} {spread:?} cycle at {t}"));
+ }
+ }
+ }
+}
+
#[test]
fn stops_inside_a_run_hold_their_colors_to_its_ends() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None };
diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs
index 0709d3068b..cd25753a05 100644
--- a/crates/color/src/gradient/tests.rs
+++ b/crates/color/src/gradient/tests.rs
@@ -514,6 +514,283 @@ fn perceptual_gradients_draw_as_pixels() {
}
}
+fn two_stops(a: Color, b: Color, method: GradientMethod) -> Gradient {
+ let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]);
+ g.method = method;
+ g
+}
+
+/// The hue methods mix around the hue wheel in one direction: Hue Counterclockwise raises the
+/// hue angle from each stop to the next and Hue Clockwise lowers it, by the fixed-direction rules
+/// of (the angles below are that
+/// section's own examples), with saturation and brightness changing evenly.
+#[test]
+fn hue_methods_turn_one_way_around_the_wheel() {
+ let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
+ let rising = two_stops(red, blue, GradientMethod::HueCounterclockwise);
+ assert_eq!(rising.sample(0.0).0.to_hex(), "#ff0000");
+ assert_eq!(rising.sample(0.25).0.to_hex(), "#ffff00", "60°");
+ assert_eq!(rising.sample(0.5).0.to_hex(), "#00ff00", "120°");
+ assert_eq!(rising.sample(1.0).0.to_hex(), "#0000ff");
+ let falling = two_stops(red, blue, GradientMethod::HueClockwise);
+ assert_eq!(falling.sample(0.0).0.to_hex(), "#ff0000");
+ assert_eq!(falling.sample(0.5).0.to_hex(), "#ff00ff", "300°");
+ assert_eq!(falling.sample(1.0).0.to_hex(), "#0000ff");
+ assert_eq!(two_stops(red, blue, GradientMethod::Classic).sample(0.5).0.to_rgb(), [0.5, 0.0, 0.5], "classic: the RGB mix");
+ // 30° to 190° and to 230°: halfway is 110° and 130° rising, 290° and 310° falling.
+ for (to, up, down) in [(190.0, 110.0, 290.0), (230.0, 130.0, 310.0)] {
+ let (a, b) = (Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(to, 0.9, 0.4));
+ for (method, want) in [(GradientMethod::HueCounterclockwise, up), (GradientMethod::HueClockwise, down)] {
+ let [h, s, v] = method.mix(&a, &b, 0.5).to_hsb();
+ assert!((h - want).abs() < 0.05, "{method:?} to {to}: hue {h}, not {want}");
+ assert!((s - 0.7).abs() < 1e-3 && (v - 0.6).abs() < 1e-3, "{method:?}: saturation {s}, brightness {v}");
+ }
+ }
+ // The other way round, each method takes the arc the other took.
+ let back = two_stops(blue, red, GradientMethod::HueClockwise);
+ assert_eq!(back.sample(0.5).0.to_hex(), "#00ff00");
+ // A position that is not a number gives the first color.
+ assert_eq!(hue_mix(&red, &blue, f32::NAN, true).to_hex(), "#ff0000");
+}
+
+/// A color without a hue does not turn the wheel: a gray takes the hue of the stop it mixes
+/// with, black its saturation too, two grays mix as grays, and two stops of one hue keep that
+/// hue, whichever way the method turns.
+#[test]
+fn hue_methods_keep_the_hue_where_a_stop_has_none() {
+ let red = Color::rgb(1.0, 0.0, 0.0);
+ let close = |c: Color, want: [f32; 3], what: &str| {
+ let got = c.to_rgb();
+ assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{what}: {got:?}, not {want:?}");
+ };
+ let green = Color::rgb(0.0, 1.0, 0.0);
+ for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
+ close(method.mix(&Color::WHITE, &green, 0.5), [0.5, 1.0, 0.5], "white to green: a light green");
+ close(method.mix(&green, &Color::WHITE, 0.5), [0.5, 1.0, 0.5], "green to white: a light green");
+ close(method.mix(&Color::rgb(0.5, 0.5, 0.5), &green, 0.5), [0.375, 0.75, 0.375], "gray to green");
+ close(method.mix(&Color::BLACK, &green, 0.5), [0.0, 0.5, 0.0], "black to green: a dark green, not a gray green");
+ close(method.mix(&green, &Color::BLACK, 0.25), [0.0, 0.75, 0.0], "green to black");
+ close(method.mix(&Color::BLACK, &Color::WHITE, 0.5), [0.5, 0.5, 0.5], "black to white: gray");
+ close(method.mix(&Color::BLACK, &Color::BLACK, 0.5), [0.0, 0.0, 0.0], "black to black");
+ let dark = Color::rgb(0.5, 0.0, 0.0);
+ close(method.mix(&red, &dark, 0.5), [0.75, 0.0, 0.0], "one hue: a red between the two");
+ close(method.mix(&red, &red, 0.5), [1.0, 0.0, 0.0], "one color stays");
+ // One hue with saturation and brightness both changing: the hue stays, and the color is
+ // the even mix of saturation and brightness, which is not Classic's RGB mix.
+ let pink = Color::rgb(1.0, 0.8, 0.8);
+ close(method.mix(&dark, &pink, 0.5), [0.75, 0.3, 0.3], "dark red to pink");
+ assert!((Color::lerp(&dark, &pink, 0.5).to_rgb()[1] - 0.4).abs() < 1e-3, "classic differs");
+ // A color whose RGB numbers are under half a color level apart has no hue of its own: a
+ // near gray leaning blue takes green's hue rather than turning from blue.
+ let near_gray = Color::rgb(0.5, 0.5, 0.5 + 0.3 / 255.0);
+ let [h, _, _] = method.mix(&near_gray, &green, 0.5).to_hsb();
+ assert!((h - 120.0).abs() < 0.5, "{method:?}: near gray to green passes hue {h}");
+ // One a whole color level apart has its hue (240°) and turns from it.
+ let bluish = Color::rgb(128.0 / 255.0, 128.0 / 255.0, 129.0 / 255.0);
+ let [h, _, _] = method.mix(&bluish, &green, 0.5).to_hsb();
+ let want = if method == GradientMethod::HueClockwise { 180.0 } else { 0.0 };
+ assert!(((h - want + 180.0).rem_euclid(360.0) - 180.0).abs() < 0.5, "{method:?}: bluish gray to green passes hue {h}, not {want}");
+ }
+}
+
+/// Hues that differ only by rounding are one hue: a color and a tint or shade of it, whose
+/// computed hue angles differ in their last digits, mix without turning the wheel, with either
+/// method, and a stop added to a ramp leaves the ramp's colors as they were. Hues a whole color
+/// level apart still turn.
+#[test]
+fn hue_methods_take_hues_within_half_a_color_level_as_one() {
+ let level = |n: u8| f32::from(n) / 255.0;
+ let rgb8 = |r: u8, g: u8, b: u8| Color::rgb(level(r), level(g), level(b));
+ let methods = [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise];
+ // Colors against their 50% tint toward white and a shade at 40% brightness. At least one
+ // pair's computed hues differ, or the test checks nothing.
+ let colors = [rgb8(60, 253, 230), rgb8(241, 194, 107), rgb8(68, 32, 130), rgb8(13, 200, 77), rgb8(250, 3, 201), rgb8(90, 91, 200)];
+ let mut uneven = 0;
+ for c in colors {
+ let [h, s, v] = c.to_hsb();
+ let tint = Color::lerp(&c, &Color::WHITE, 0.5);
+ let shade = Color::from_hsb(h, s, v * 0.4);
+ for other in [tint, shade] {
+ let [h1, _, _] = other.to_hsb();
+ assert!((h1 - h).abs() < 0.01, "{c:?} and {other:?} are one hue: {h} and {h1}");
+ uneven += usize::from(h1 != h);
+ for method in methods {
+ for (a, b) in [(c, other), (other, c)] {
+ for k in 0..=20 {
+ let [hm, _, _] = method.mix(&a, &b, k as f32 / 20.0).to_hsb();
+ let off = (hm - h + 180.0).rem_euclid(360.0) - 180.0;
+ assert!(off.abs() < 0.5, "{method:?} {a:?} → {b:?} at {k}/20: hue {hm}, not {h}");
+ }
+ }
+ }
+ }
+ }
+ assert!(uneven > 0, "no pair's hues differ by rounding");
+ // Across 0°: 359.99° and 0.01° are one hue too.
+ for method in methods {
+ let [h, _, _] = method.mix(&Color::from_hsb(359.99, 1.0, 1.0), &Color::from_hsb(0.01, 1.0, 1.0), 0.5).to_hsb();
+ assert!(!(1.0..=359.0).contains(&h), "{method:?} across 0°: {h}");
+ }
+ // A stop added to a ramp samples the ramp's color there, and the ramp keeps its colors:
+ // white to red, a color to its tint, and a turn from red to blue.
+ let ramps = [
+ (Color::WHITE, rgb8(255, 0, 0)),
+ (rgb8(60, 253, 230), Color::lerp(&rgb8(60, 253, 230), &Color::WHITE, 0.5)),
+ (rgb8(255, 0, 0), rgb8(0, 0, 255)),
+ ];
+ for method in methods {
+ for (a, b) in ramps {
+ let g = two_stops(a, b, method);
+ for at in [0.21, 0.5, 0.83] {
+ let (stops, i) = insert_stop(&g, at);
+ assert_eq!((stops.len(), i), (3, 1));
+ let with = Gradient { stops, ..g.clone() };
+ for k in 0..=200 {
+ let t = k as f32 / 200.0;
+ let (was, now) = (g.sample(t).0.to_rgb(), with.sample(t).0.to_rgb());
+ let off = was.iter().zip(now).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
+ assert!(off < 1.0 / 255.0, "{method:?} {a:?} → {b:?}, a stop at {at}: {was:?} became {now:?} at {t}");
+ }
+ }
+ }
+ }
+ // One color level of hue is a hue of its own: red to a red one level toward yellow turns
+ // nearly the whole wheel one way and stays put the other.
+ let (red, next) = (rgb8(255, 0, 0), rgb8(255, 1, 0));
+ let [h, _, _] = GradientMethod::HueClockwise.mix(&red, &next, 0.5).to_hsb();
+ assert!((h - 180.0).abs() < 1.0, "the long way: {h}");
+ let [h, _, _] = GradientMethod::HueCounterclockwise.mix(&red, &next, 0.5).to_hsb();
+ assert!(h < 1.0, "the short way: {h}");
+}
+
+/// Renderers mix linearly between stops: a hue segment becomes stops on its way around the
+/// wheel, close enough that the linear ramp between them stays within half a color level of it,
+/// through the midpoint and with the opacity changing evenly.
+#[test]
+fn hue_segments_expand_into_stops_on_the_curve() {
+ let pairs = [
+ (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)),
+ (Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 0.02, 0.0)),
+ (Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(190.0, 0.9, 0.4)),
+ (Color::cmyk(0.0, 0.9, 0.9, 0.0), Color::WHITE),
+ (Color::BLACK, Color::rgb(0.0, 1.0, 0.0)),
+ ];
+ for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
+ for (a, b) in pairs {
+ let mut g = two_stops(a, b, method);
+ g.stops[0].midpoint = 0.3;
+ g.stops[1].opacity = 0.5;
+ let ex: Vec<_> = g.expanded().collect();
+ assert!((3..=600).contains(&ex.len()), "{method:?} {a:?} → {b:?}: {} stops", ex.len());
+ assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order");
+ assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6), "a stop stands for the midpoint");
+ let ramp = Ramp::new(&g, &Color::to_rgb);
+ for k in 0..=4000 {
+ let t = k as f32 / 4000.0;
+ let (c, o) = g.sample(t);
+ let [r, gr, bl] = c.to_rgb();
+ let off = [r, gr, bl, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
+ assert!(off < 0.5 / 255.0, "{method:?} {a:?} → {b:?} at {t}: {off}");
+ }
+ assert_eq!(g.sample_keep_model(0.4), g.sample(0.4), "one mix");
+ }
+ }
+}
+
+/// Reversing a gradient gives the same ramp the other way round: a hue method turns the other
+/// way with the stops, so every color keeps its place mirrored, and reversing twice gives the
+/// gradient back. Classic and Perceptual keep their method. A backward span and the curve as a
+/// Classic gradient hold the same colors.
+#[test]
+fn reversing_a_hue_gradient_keeps_its_colors_mirrored() {
+ let near = |a: Color, b: Color, what: &str| {
+ assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}");
+ };
+ for (method, other) in
+ [(GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise), (GradientMethod::HueCounterclockwise, GradientMethod::HueClockwise)]
+ {
+ let mut g = Gradient::new(
+ GradientKind::Linear,
+ vec![
+ GradientStop { midpoint: 0.3, ..GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)) },
+ GradientStop { opacity: 0.5, ..GradientStop::new(0.6, Color::rgb(0.0, 0.0, 1.0)) },
+ GradientStop::new(1.0, Color::rgb(0.9, 0.8, 0.1)),
+ ],
+ );
+ g.method = method;
+ let mut back = g.clone();
+ back.reverse();
+ assert_eq!(back.method, other);
+ let (span, curve) = (g.span(1.0, 0.0), g.as_classic_curve());
+ assert!(span.method.is_classic() && curve.method.is_classic());
+ assert!(curve.stops.len() > g.stops.len() && curve.stops.iter().all(|s| s.swatch.is_none() && s.midpoint == 0.5));
+ for k in 0..=400 {
+ let t = k as f32 / 400.0;
+ let (want, opacity) = g.sample(1.0 - t);
+ near(want, back.sample(t).0, &format!("{method:?} reversed at {t}"));
+ assert!((opacity - back.sample(t).1).abs() < 1e-4, "{method:?} opacity at {t}");
+ near(want, span.sample(t).0, &format!("{method:?} backward span at {t}"));
+ near(g.sample(t).0, curve.sample(t).0, &format!("{method:?} curve at {t}"));
+ }
+ back.reverse();
+ assert_eq!(back.method, method);
+ assert_eq!(back.stops.len(), g.stops.len());
+ }
+ for method in [GradientMethod::Classic, GradientMethod::Perceptual] {
+ assert_eq!(method.mirrored(), method);
+ let mut g = blue_yellow(method);
+ g.reverse();
+ assert_eq!(g.method, method);
+ }
+}
+
+/// The hue methods are written by name, read back, and parsed from their param names; their
+/// gradients draw as pixels in SVG and PDF, and are not mixes of the stops' inks.
+#[test]
+fn hue_methods_save_parse_and_draw_as_pixels() {
+ for (method, name, id) in [
+ (GradientMethod::HueClockwise, "HueClockwise", "hue-clockwise"),
+ (GradientMethod::HueCounterclockwise, "HueCounterclockwise", "hue-counterclockwise"),
+ ] {
+ let g = two_stops(Color::cmyk(0.0, 1.0, 1.0, 0.0), Color::cmyk(1.0, 1.0, 0.0, 0.0), method);
+ let v = serde_json::to_value(&g).unwrap();
+ assert_eq!(v["method"], name);
+ assert_eq!(serde_json::from_value::(v).unwrap(), g);
+ assert_eq!(method.id(), id);
+ for written in [id.to_string(), id.to_uppercase(), id.replace('-', " "), id.replace('-', "_"), name.to_string(), method.label().to_string()] {
+ assert_eq!(GradientMethod::parse(&written), Some(method), "{written}");
+ }
+ assert!(method.is_hue() && !method.is_classic());
+ let paint = GradientPaint::new(g.clone());
+ assert!(paint.draws_as_pixels(true) && paint.draws_as_pixels(false));
+ assert!(g.ink_mixes().is_none(), "not mixes of the stops' inks");
+ assert!(
+ paint.ink_pixels(Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(0.0, 0.0, 10.0, 10.0), 4, 4, true).is_none(),
+ "an RGB image, not an ink image"
+ );
+ }
+ assert!(!GradientMethod::Perceptual.is_hue() && !GradientMethod::Classic.is_hue());
+ for (id, method) in [("classic", GradientMethod::Classic), ("perceptual", GradientMethod::Perceptual)] {
+ assert_eq!(method.id(), id);
+ assert_eq!(GradientMethod::parse(id), Some(method));
+ }
+ for bad in [
+ "hue",
+ "clockwise",
+ "hue-",
+ "",
+ "hue clockwise wise",
+ "c-l-a-s-s-i-c",
+ "per ceptual",
+ "hue--clockwise",
+ "hue-clock_wise",
+ " classic",
+ "hue- clockwise",
+ ] {
+ assert_eq!(GradientMethod::parse(bad), None, "{bad}");
+ }
+}
+
/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on
/// the curve has the lightness of the color mixed there, so the tints between the stops are not
/// a straight line.
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 0d84e9e6b3..3afca8063c 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -102,12 +102,13 @@ impl Color {
pub fn to_lab(&self) -> cms::Lab {
cms::active().lab(self)
}
- /// Colour-managed CMYK in the working CMYK space with `intent` (see [`Color::to_cmyk`] for the
- /// profile-free formula).
+ /// Color-managed CMYK in the active CMYK space (the front document's assigned CMYK profile, else
+ /// the working CMYK space) with `intent` (see [`Color::to_cmyk`] for the profile-free formula).
pub fn to_cmyk_managed(&self, intent: cms::Intent) -> [f32; 4] {
cms::active().to_cmyk(self, intent)
}
- /// Gamut warning: true when this colour can't be reproduced in the working CMYK space.
+ /// Gamut warning: true when this color can't be reproduced in the active CMYK space (the front
+ /// document's assigned CMYK profile, else the working CMYK space).
pub fn out_of_gamut(&self) -> bool {
cms::active().out_of_gamut(self)
}
diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs
index 0aeda59731..340cf98459 100644
--- a/crates/engine/src/cmd/arrowheads.rs
+++ b/crates/engine/src/cmd/arrowheads.rs
@@ -18,7 +18,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Nod
use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath};
use vectorcraft_pathops::BoolOp;
-use super::{CommandSpec, Result, Session, always, bad, cmd};
+use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd};
/// The arrowheads file's name, in the folder of the preferences.
pub const FILE_NAME: &str = "Arrowheads.vectorcraft";
@@ -37,7 +37,7 @@ pub fn specs() -> Vec {
"Arrowheads",
[],
None,
- "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus",
+ "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web, or the file is outside the automation read roots), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read (naming the file by its file name outside the read roots), or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus",
always,
list
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec {
"Edit Arrowheads",
[],
None,
- "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}",
+ "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads. Under automation read roots, a file outside them is an error, and nothing is made or opened → {path, created: whether the file was made now}",
has_file,
edit
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec {
"Reload Arrowheads",
[],
None,
- "{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}",
+ "{} read the arrowheads file again (a save of it from VectorCraft does this already; under automation read roots, a file outside them is an error and is not read) → {count, skipped: [{name, reason}]}",
has_file,
reload
),
@@ -409,13 +409,36 @@ fn skipped(s: &Session) -> Vec {
s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect()
}
+/// The arrowheads file at `path` checked for reading against the automation roots in force: an agent
+/// confined to read roots reaches the file only inside them. The error names the file by its file
+/// name outside them.
+fn may_reach(path: &str) -> Result<()> {
+ crate::file_access::check_read(path).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), path))
+}
+
+/// `msg`, an error about the arrowheads file at `path`, as the request being served may see it: the
+/// file named whole inside the read roots, else by its file name.
+fn shown_error(msg: &str, path: Option<&str>) -> String {
+ let Some(path) = path else { return msg.to_string() };
+ match super::fileio::naming_in_error(EngineError::Other(msg.to_string()), path) {
+ EngineError::Other(m) => m,
+ e => e.to_string(),
+ }
+}
+
fn list(s: &mut Session, _: &Value) -> Result {
let heads: Vec = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect();
- Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error}))
+ let path = s.arrowheads.path.as_deref();
+ let file = path.filter(|p| crate::file_access::check_read(p).is_ok());
+ let error = s.arrowheads.error.as_deref().map(|e| shown_error(e, path));
+ Ok(json!({"arrowheads": heads, "file": file, "skipped": skipped(s), "error": error}))
}
fn reload(s: &mut Session, _: &Value) -> Result {
const C: &str = "stroke.arrowheads.reload";
+ if let Some(path) = &s.arrowheads.path {
+ may_reach(path)?;
+ }
s.reload_arrowheads();
if let Some(e) = &s.arrowheads.error {
return Err(bad(C, e.clone()));
@@ -426,8 +449,11 @@ fn reload(s: &mut Session, _: &Value) -> Result {
fn edit(s: &mut Session, _: &Value) -> Result {
const C: &str = "stroke.arrowheads.edit";
let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?;
+ // Checked before anything is made, brought to the front or opened.
+ may_reach(&path)?;
if let Some(dir) = std::path::Path::new(&path).parent() {
- std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
+ let dir = dir.to_string_lossy();
+ std::fs::create_dir_all(&*dir).map_err(|e| super::fileio::naming_in_error(bad(C, format!("{dir}: {e}")), &dir))?;
}
// Made only when no file is there, also when another run makes one at the same moment.
let bytes = vectorcraft_format::save_file(&starter());
@@ -446,7 +472,8 @@ fn edit(s: &mut Session, _: &Value) -> Result {
s.set_active(i);
}
None => {
- crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?;
+ // Opened in the request's own scope, so the document keeps the roots it was opened under.
+ s.execute("document.open", &json!({ "path": path }))?;
}
}
Ok(json!({"path": path, "created": created}))
diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs
index 6c75eae128..c94de5d3b2 100644
--- a/crates/engine/src/cmd/colormgmt.rs
+++ b/crates/engine/src/cmd/colormgmt.rs
@@ -90,7 +90,7 @@ pub fn specs() -> Vec {
"Gamut Warning",
[],
None,
- "{ids?|id?, colors?: [color] (#rrggbbaa and #rgba are refused)} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
+ "{ids?|id?, colors?: [color] (#rrggbbaa and #rgba are refused)} colors that can't be printed in the document's assigned CMYK profile, else the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
has_doc,
gamut_check
),
diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs
index 352e520433..ad860b453a 100644
--- a/crates/engine/src/cmd/expand.rs
+++ b/crates/engine/src/cmd/expand.rs
@@ -1388,8 +1388,9 @@ fn band_edges(lo: f64, hi: f64, n: usize, runs: Option<(f64, usize)>) -> Vec Vec<(f64, (Color, f32))> {
let at = |t: f64| (t, g.sample_keep_model(t as f32));
let mut v = vec![at(t0)];
- // A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops
- // its expansion adds, so the linear mix between columns follows that curve.
+ // A Perceptual gradient, or one mixed around the hue wheel, curves between its stops: the mesh
+ // takes a column at each of the stops its expansion adds, so the linear mix between columns
+ // follows that curve.
if !g.method.is_classic() {
v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at));
}
diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs
index 333db1c68f..9154ce7ee5 100644
--- a/crates/engine/src/cmd/fileio.rs
+++ b/crates/engine/src/cmd/fileio.rs
@@ -69,8 +69,8 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
-pub(crate) use export::{export_crop, isolated};
-pub use export::{export_doc, export_source};
+pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
+pub use export::{export_doc, export_source, unused_option_notes};
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::is_placeholder;
diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs
index 9e95948def..e867fdee41 100644
--- a/crates/engine/src/cmd/fileio/commands.rs
+++ b/crates/engine/src/cmd/fileio/commands.rs
@@ -27,7 +27,7 @@ pub fn specs() -> Vec {
"Serialize Document",
[],
None,
- "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
+ "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it",
has_doc,
export::serialize
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec {
"Export Document",
[],
None,
- "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
+ "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Never changes the document's path",
has_doc,
export::export
),
@@ -45,7 +45,7 @@ pub fn specs() -> Vec {
"Export Selection…",
["File"],
None,
- "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
+ "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it",
has_selection,
export::export_selection
),
diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs
index 19e496306f..1ae96c6f81 100644
--- a/crates/engine/src/cmd/fileio/export.rs
+++ b/crates/engine/src/cmd/fileio/export.rs
@@ -15,8 +15,45 @@ use super::{
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
};
+/// The keys the export commands take for every format, beside the path, the format and the
+/// options `document.formats` lists for each: `richBlack` is Appearance of Black's setting for a
+/// raster export, and `params` an exporter plug-in's own.
+pub(crate) const EXPORT_PARAMS: [&str; 4] = ["includeArtboardFill", "selectedOnly", "richBlack", "params"];
+
+/// A note for each option in `p` that some format takes but the file an export of `cmd` writes in
+/// `f` doesn't read (`{format: pdf, quality: 80}`), in the wording of a save's note. An exporter
+/// plug-in's format gets none. The notes cover the formats' options only: a key of the command's
+/// own ([`EXPORT_PARAMS`]) and an artboard choice that a selection export leaves out get none.
+pub fn unused_option_notes(cmd: &str, f: &Format, p: &Value) -> Vec {
+ let Some(given) = p.as_object() else { return Vec::new() };
+ if vectorcraft_plugins::export::by_format(f.id).is_some() {
+ return Vec::new();
+ }
+ let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
+ let written = super::save::written_as(cmd, f, p);
+ let legacy = f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false);
+ let reads = |k: &str| match (f.id, k) {
+ // A legacy `.ai` file is not a PDF: it reads its own options only.
+ ("ai", _) if legacy => super::legacy_ai::reads(k),
+ // The other `.ai` files don't read the legacy file's options, and `preserveEditing` is
+ // on for them whatever is given.
+ ("ai", "preserveEditing") => false,
+ ("ai", _) if super::legacy_ai::LEGACY_ONLY.contains(&k) => false,
+ // An export writes one file: only a save writes each artboard to a file of its own, and
+ // a native file's `range` names those files.
+ ("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
+ _ => super::save::reads_option(f, k),
+ };
+ given
+ .iter()
+ .filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !reads(k))
+ .map(|(k, _)| super::save::unused_option_note(k, &written))
+ .collect()
+}
+
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result {
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
+ let unused = unused_option_notes("document.serialize", f, p);
let expanded = expand_presets(s, "document.serialize", p)?;
let p = with_black_output(s, &expanded);
let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?;
@@ -27,7 +64,8 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result {
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
- let enc = encode_all(doc, f.id, &q)?;
+ let mut enc = encode_all(doc, f.id, &q)?;
+ enc.add_setting_notes(unused);
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
let (main, linked) = files.split_at(enc.files.len());
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
@@ -58,6 +96,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result {
super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?;
}
let f = writable("document.export", text_param(p, "format", "document.export")?, path)?;
+ let unused = unused_option_notes("document.export", f, p);
let expanded = expand_presets(s, "document.export", p)?;
let p = with_black_output(s, &expanded);
let (doc, display_doc_title) = source(s, f, &p, "document.export")?;
@@ -68,7 +107,8 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result {
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
- let enc = encode_all(doc, f.id, &q)?;
+ let mut enc = encode_all(doc, f.id, &q)?;
+ enc.add_setting_notes(unused);
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() }))
}
@@ -315,12 +355,18 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let f = writable(C, text_param(p, "format", C)?, path)?;
+ let unused = unused_option_notes(C, f, p);
let expanded = expand_presets(s, C, p)?;
let p = with_black_output(s, &expanded);
let (d, bounds) = selection(s, C, f.id == "txt")?;
// The encoder's warnings (options accepted but not applied, features approximated or left
// out), as document.export reports them.
- let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
+ let (bytes, mut warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
+ for note in unused {
+ if !warnings.contains(¬e) {
+ warnings.push(note);
+ }
+ }
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
}
diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs
index 05b6e03551..23168f1e25 100644
--- a/crates/engine/src/cmd/fileio/legacy_ai.rs
+++ b/crates/engine/src/cmd/fileio/legacy_ai.rs
@@ -65,6 +65,18 @@ pub(super) fn named(f: &Format) -> Format {
Format { label: "legacy .ai (version 7)", ..*f }
}
+/// The `.ai` options that only a legacy file reads: a PDF-compatible `.ai` file and a `.ai` file
+/// without PDF content don't.
+pub(super) const LEGACY_ONLY: [&str; 3] = ["transparentImages", "hiddenObjects", "outlineText"];
+
+/// Does an export of a legacy file read option `key`? It reads the version, the file's own
+/// options ([`LEGACY_ONLY`]), the artboards to write, and `useArtboards`, which the export reads
+/// before [`encode`] runs. It reads none of the other `.ai` options and none of the PDF options
+/// that a PDF-compatible `.ai` file takes.
+pub(super) fn reads(key: &str) -> bool {
+ matches!(key, "aiVersion" | "useArtboards") || LEGACY_ONLY.contains(&key) || super::ARTBOARD_PARAMS.contains(&key)
+}
+
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SeeThrough {
diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs
index 6a2a0986a3..0a306ee1a9 100644
--- a/crates/engine/src/cmd/fileio/save.rs
+++ b/crates/engine/src/cmd/fileio/save.rs
@@ -317,7 +317,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result {
let notes = top
.filter(|k| !reads_option(format, k))
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
- .map(|k| format!("`{k}` was not used: {} files don't read it", written.label))
+ .map(|k| unused_option_note(&k, &written))
.collect();
let lossy_source = super::lossy_source(st, p);
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
@@ -467,6 +467,12 @@ pub fn reads_option(f: &Format, key: &str) -> bool {
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
}
+/// The note a save or an export gives for option `key`, as the request names it, which the file it
+/// writes (`written`, see [`written_as`]) doesn't read.
+pub(super) fn unused_option_note(key: &str, written: &Format) -> String {
+ format!("`{key}` was not used: {} files don't read it", written.label)
+}
+
/// Formats that carry the whole native document: they lose nothing, and record links relative to
/// where they are written. A `.ai` or `.ait` file is a PDF carrying it; EPS carries it in comments.
fn is_native(f: &Format) -> bool {
@@ -636,7 +642,7 @@ fn without_pdf(f: &Format) -> Format {
/// `f` named, in its errors and notes, as the file a save with options `p` writes: a version 7
/// `.ai` as the legacy `.ai`, and a `.ai` or `.ait` with `pdfCompatible: false` as one saved
/// without PDF content.
-fn written_as(cmd: &str, f: &Format, p: &Value) -> Format {
+pub(super) fn written_as(cmd: &str, f: &Format, p: &Value) -> Format {
if f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false) {
super::legacy_ai::named(f)
} else if matches!(f.id, "ai" | "ait") && !bool_or(p, "pdfCompatible", true) {
diff --git a/crates/engine/src/cmd/fileio/tests_exportas.rs b/crates/engine/src/cmd/fileio/tests_exportas.rs
index f3566e82ba..cc1345060d 100644
--- a/crates/engine/src/cmd/fileio/tests_exportas.rs
+++ b/crates/engine/src/cmd/fileio/tests_exportas.rs
@@ -141,6 +141,137 @@ fn what_the_file_loses_comes_before_a_note_on_the_options() {
assert_eq!(r["warnings"], json!(w));
}
+/// A session with one artboard and a selected rectangle on it.
+fn with_art() -> Session {
+ let mut s = session(60.0, 40.0, 1);
+ s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
+ s
+}
+
+/// The warnings of reply `r` that name an option the file doesn't read.
+fn unused(r: &Value) -> Vec {
+ r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
+}
+
+/// An option that only another format reads is accepted, and the reply's warnings name it in the
+/// words of a save's note; `document.export`, `document.serialize` and `document.exportSelection`
+/// give the same note.
+#[test]
+fn an_option_only_another_format_reads_gets_a_note() {
+ let (mut s, notes) = (with_art(), unused);
+ for cmd in ["document.export", "document.serialize", "document.exportSelection"] {
+ let r = s.execute(cmd, &json!({"format": "pdf", "quality": 80, "outlineText": true})).unwrap();
+ assert_eq!(
+ notes(&r),
+ ["`outlineText` was not used: PDF files don't read it", "`quality` was not used: PDF files don't read it"],
+ "{cmd}: {r}"
+ );
+ }
+ // The format's own options, the keys every export takes and null values get none.
+ for p in [
+ json!({"format": "jpg", "quality": 80, "scale": 2}),
+ json!({"format": "ai", "compress": true, "compatibility": "1.7"}),
+ json!({"format": "pdf", "quality": null, "selectedOnly": false, "includeArtboardFill": false, "useArtboards": true}),
+ json!({"format": "svg", "outlineText": true, "svg": {"decimals": 2}}),
+ ] {
+ let r = s.execute("document.export", &p).unwrap();
+ assert!(notes(&r).is_empty(), "{p}: {r}");
+ }
+ let r = s.execute("document.serialize", &json!({"format": "png", "compress": true, "marks": {"trim": true}, "svg": {"minify": true}})).unwrap();
+ assert_eq!(
+ notes(&r),
+ [
+ "`compress` was not used: PNG files don't read it",
+ "`marks` was not used: PNG files don't read it",
+ "`svg` was not used: PNG files don't read it"
+ ],
+ "{r}"
+ );
+ // The note names the kind of file written, as a save's does.
+ let r = s.execute("document.export", &json!({"format": "ai", "pdfCompatible": false, "quality": 80})).unwrap();
+ assert_eq!(notes(&r), ["`quality` was not used: non-PDF-compatible .ai files don't read it"], "{r}");
+ let r = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false, "range": "1"})).unwrap();
+ assert!(notes(&r).is_empty(), "{r}");
+ let r = s.execute("document.export", &json!({"format": "vectorcraft", "artboard": 0})).unwrap();
+ assert_eq!(notes(&r), ["`artboard` was not used: VectorCraft files don't read it"], "{r}");
+}
+
+/// A legacy `.ai` file reads its own options and the artboards to write, not the other `.ai`
+/// options or the PDF options.
+#[test]
+fn a_legacy_ai_export_notes_the_options_of_the_other_ai_files() {
+ let (mut s, notes) = (with_art(), unused);
+ let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "compress": true, "marks": {"trim": true}})).unwrap();
+ assert_eq!(
+ notes(&r),
+ ["`compress` was not used: legacy .ai (version 7) files don't read it", "`marks` was not used: legacy .ai (version 7) files don't read it"],
+ "{r}"
+ );
+ let r =
+ s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "outlineText": true, "hiddenObjects": "omit", "range": "1"})).unwrap();
+ assert!(notes(&r).is_empty(), "{r}");
+ let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "useArtboards": false})).unwrap();
+ assert!(notes(&r).is_empty(), "{r}");
+}
+
+/// The `.ai` files other than the legacy one don't read the legacy file's own options.
+#[test]
+fn a_pdf_compatible_ai_export_notes_the_legacy_options() {
+ let (mut s, notes) = (with_art(), unused);
+ let unread = json!({"format": "ai", "aiVersion": "pdf", "outlineText": true, "hiddenObjects": "omit", "transparentImages": "omit"});
+ let r = s.execute("document.export", &unread).unwrap();
+ assert_eq!(
+ notes(&r),
+ [
+ "`hiddenObjects` was not used: PDF-compatible .ai files don't read it",
+ "`outlineText` was not used: PDF-compatible .ai files don't read it",
+ "`transparentImages` was not used: PDF-compatible .ai files don't read it"
+ ],
+ "{r}"
+ );
+}
+
+/// `preserveEditing` is always on for a `.ai` file, so an export that gives it gets a note.
+#[test]
+fn an_ai_export_notes_preserve_editing() {
+ let (mut s, notes) = (with_art(), unused);
+ let r = s.execute("document.export", &json!({"format": "ai", "preserveEditing": false, "range": "1"})).unwrap();
+ assert_eq!(notes(&r), ["`preserveEditing` was not used: PDF-compatible .ai files don't read it"], "{r}");
+}
+
+/// An export writes one file: only a save writes each artboard to a file of its own, so an export
+/// notes `separateArtboards`, and the `range` that names those files for a native file.
+#[test]
+fn an_export_notes_the_options_that_only_a_save_reads() {
+ let (mut s, notes) = (with_art(), unused);
+ let r = s.execute("document.export", &json!({"format": "ai", "separateArtboards": true, "range": "1"})).unwrap();
+ assert_eq!(notes(&r), ["`separateArtboards` was not used: PDF-compatible .ai files don't read it"], "{r}");
+ for (format, label) in [("vectorcraft", "VectorCraft"), ("template", "VectorCraft Template")] {
+ let r = s.execute("document.export", &json!({"format": format, "separateArtboards": true, "range": "1", "compress": true})).unwrap();
+ assert_eq!(
+ notes(&r),
+ [format!("`range` was not used: {label} files don't read it"), format!("`separateArtboards` was not used: {label} files don't read it")],
+ "{r}"
+ );
+ }
+}
+
+/// The legacy `.ai` options that the notes go by are the ones the `.ai` option table describes as
+/// the legacy file's ("with aiVersion 7"), so an option added to the table is not noted as unread
+/// on a legacy export by mistake.
+#[test]
+fn the_legacy_ai_options_are_the_ones_the_option_table_names() {
+ let described: Vec<&str> = native::AI_OPTIONS.iter().filter(|o| o.description.starts_with("with aiVersion 7")).map(|o| o.name).collect();
+ assert_eq!(described, legacy_ai::LEGACY_ONLY);
+ for key in described.iter().chain(&ARTBOARD_PARAMS).chain(&["aiVersion", "useArtboards"]) {
+ assert!(legacy_ai::reads(key), "{key}");
+ assert!(native::AI_OPTIONS.iter().any(|o| o.name == *key), "{key} is not a .ai option");
+ }
+ for key in ["compress", "pdfCompatible", "separateArtboards", "editingData", "marks"] {
+ assert!(!legacy_ai::reads(key), "{key}");
+ }
+}
+
#[test]
fn note_texts_give_the_losses_first_each_kind_in_order() {
use super::encode::{Note, note_texts};
diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs
index 0b560e5e04..b7b0ddde59 100644
--- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs
+++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs
@@ -15,9 +15,8 @@ fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap()
}
-fn tmp(name: &str) -> String {
- let dir = std::env::temp_dir().join(format!("vc-pdfedit-{}", std::process::id()));
- std::fs::create_dir_all(&dir).unwrap();
+/// The path of `name` in the test's folder `dir`.
+fn tmp(dir: &std::path::Path, name: &str) -> String {
dir.join(name).to_string_lossy().to_string()
}
@@ -114,8 +113,9 @@ fn the_legacy_payload_name_is_accepted() {
#[test]
fn ai_files_are_pdfs_that_save_and_reopen_editable() {
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
- let path = tmp("art.ai");
+ let path = tmp(&scratch, "art.ai");
let r = s.execute("document.save", &json!({"path": path, "preserveEditing": false, "range": "1"})).unwrap();
assert_eq!(r["path"], path.as_str());
// As saved (Save stamps the File Info dates).
@@ -134,11 +134,11 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() {
let again = vectorcraft_pdf::editing(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(vectorcraft_format::load(&again.data).unwrap().layers, s.doc().unwrap().doc.layers);
// Save As also writes a plain PDF (the document then saves back as PDF); PNG stays an export.
- assert_eq!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).unwrap()["format"], "pdf");
- assert!(s.execute("document.save", &json!({"path": tmp("x.png")})).is_err(), "PNG is written with document.export");
+ assert_eq!(s.execute("document.save", &json!({"path": tmp(&scratch, "x.pdf")})).unwrap()["format"], "pdf");
+ assert!(s.execute("document.save", &json!({"path": tmp(&scratch, "x.png")})).is_err(), "PNG is written with document.export");
// A plain PDF named .ai opens as artwork, without a path to save over.
let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap());
- let plain_path = tmp("plain.ai");
+ let plain_path = tmp(&scratch, "plain.ai");
std::fs::write(&plain_path, plain).unwrap();
let r = s.execute("document.open", &json!({"path": plain_path})).unwrap();
assert_eq!(r["restored"], false);
@@ -149,9 +149,10 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() {
/// As does, leaving the document's own path alone.
#[test]
fn exports_write_ai_as_save_as_does() {
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
let before = comparable(&s.doc().unwrap().doc);
- let path = tmp("exported.ai");
+ let path = tmp(&scratch, "exported.ai");
let r = s.execute("document.export", &json!({"path": path})).unwrap();
assert_eq!(r["format"], "ai", "{r}");
let bytes = std::fs::read(&path).unwrap();
@@ -225,12 +226,13 @@ fn ait_templates_open_their_document_untitled() {
/// from Template opens it as an untitled copy of the document, layers and art included.
#[test]
fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
s.execute("layer.new", &json!({"name": "Notes"})).unwrap();
s.execute("shape.ellipse", &json!({"x": 20, "y": 120, "width": 40, "height": 30})).unwrap();
let before = comparable(&s.doc().unwrap().doc);
assert_eq!(before.layers.len(), 2);
- let path = tmp("brochure template.ait");
+ let path = tmp(&scratch, "brochure template.ait");
let r = s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
assert_eq!(r["format"], "ait", "{r}");
let bytes = std::fs::read(&path).unwrap();
@@ -253,6 +255,7 @@ fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
/// writes a `.ait`.
#[test]
fn save_as_template_names_its_file_after_the_template_format() {
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
for (name, format, saved) in [
("card.png", None, "card.vctemplate"),
@@ -261,7 +264,7 @@ fn save_as_template_names_its_file_after_the_template_format() {
("card.pdf", Some("ait"), "card.ait"),
("card v1.2", None, "card v1.2.vctemplate"),
] {
- let (typed, want) = (tmp(&format!("named/{name}")), tmp(&format!("named/{saved}")));
+ let (typed, want) = (tmp(&scratch, &format!("named/{name}")), tmp(&scratch, &format!("named/{saved}")));
let _ = std::fs::create_dir_all(std::path::Path::new(&typed).parent().unwrap());
let _ = std::fs::remove_file(&typed);
let _ = std::fs::remove_file(&want);
@@ -273,7 +276,7 @@ fn save_as_template_names_its_file_after_the_template_format() {
assert!(ait || vectorcraft_format::load(&bytes).is_ok_and(|d| d.template), "{name}: a native template");
assert!(!std::path::Path::new(&typed).exists(), "{name}: nothing written under the typed name");
}
- for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp("card.ait")}))] {
+ for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp(&scratch, "card.ait")}))] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("file.saveAsTemplate") && !e.contains("document.export"), "{cmd}: {e}");
}
@@ -284,8 +287,9 @@ fn save_as_template_names_its_file_after_the_template_format() {
/// nothing writes keeps its own error.
#[test]
fn exports_of_an_ait_name_save_as_template() {
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
- let path = tmp("exported.ait");
+ let path = tmp(&scratch, "exported.ait");
let _ = std::fs::remove_file(&path);
for (cmd, p) in [
("document.export", json!({"path": path})),
@@ -305,8 +309,9 @@ fn exports_of_an_ait_name_save_as_template() {
/// file; `aiVersion` "pdf" writes the PDF-compatible template.
#[test]
fn save_as_template_refuses_a_legacy_ait() {
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = rich();
- let path = tmp("legacy-ait/card.ait");
+ let path = tmp(&scratch, "legacy-ait/card.ait");
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
@@ -424,7 +429,8 @@ fn an_ai_of_the_arts_bounds_carries_no_editing_data() {
let mut s = art_outside_the_artboards();
let before = comparable(&s.doc().unwrap().doc);
let left_out = |v: &Value| v["warnings"].as_array().unwrap().iter().any(|w| w == super::save::AI_EDITING_NEEDS_THE_ARTBOARDS);
- let path = tmp("art-bounds.ai");
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
+ let path = tmp(&scratch, "art-bounds.ai");
for extra in [json!({}), json!({"range": "2"}), json!({"editingData": false}), json!({"path": path})] {
let mut params = json!({"format": "ai", "useArtboards": false});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
@@ -670,6 +676,7 @@ fn editing_copy(bytes: &[u8]) -> vectorcraft_doc::Document {
/// writes the copy; the version 7 `.ai` keeps the compound path and the clipping group.
#[test]
fn transparency_clipping_and_opacity_masks_come_back_from_the_editing_copy() {
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = see_through_session();
let before = (*s.doc().unwrap().doc).clone();
// The mask's art is one path, and it reads back as that path.
@@ -682,7 +689,7 @@ fn transparency_clipping_and_opacity_masks_come_back_from_the_editing_copy() {
assert_eq!(see_through_outline(&editing_copy(&bytes)), want, "{opts}");
}
// Each artboard in a file of its own: the first holds all the art.
- let path = tmp("see-through.ai");
+ let path = tmp(&scratch, "see-through.ai");
let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap();
let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
assert_eq!(files.len(), 3, "{r}");
@@ -812,7 +819,8 @@ fn image_outline(doc: &vectorcraft_doc::Document) -> Vec {
/// the linked image keeps its path and its path relative to the document's folder.
#[test]
fn images_come_back_from_the_editing_copy() {
- let dir = tmp("ai-images");
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
+ let dir = tmp(&scratch, "ai-images");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(format!("{dir}/art")).unwrap();
let png = |w: u32, h: u32, f: &dyn Fn(u32, u32) -> [u8; 4]| {
@@ -882,6 +890,7 @@ fn type_outline(doc: &vectorcraft_doc::Document) -> Vec {
/// for other apps alone as type, with every save option that writes the copy.
#[test]
fn type_comes_back_from_the_editing_copy() {
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("text.create", &json!({"x": 20, "y": 40, "text": "Hello\nWorld", "size": 24})).unwrap();
@@ -889,7 +898,7 @@ fn type_comes_back_from_the_editing_copy() {
// The new type is selected.
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
s.execute("text.create", &json!({"x": 20, "y": 120, "text": "Area type in a frame", "area": {"width": 120, "height": 60}})).unwrap();
- let path = tmp("type.ai");
+ let path = tmp(&scratch, "type.ai");
s.execute("document.save", &json!({"path": path})).unwrap();
let before = type_outline(&s.doc().unwrap().doc);
assert_eq!(before.len(), 3, "{before:#?}");
@@ -971,6 +980,7 @@ fn gradient_session() -> Session {
/// gradient.
#[test]
fn gradients_and_patterns_come_back_from_the_editing_copy() {
+ let scratch = vectorcraft_testkit::TempDir::new("pdfedit");
let mut s = gradient_session();
let before = (*s.doc().unwrap().doc).clone();
let want = paint_outline(&before);
@@ -990,7 +1000,7 @@ fn gradients_and_patterns_come_back_from_the_editing_copy() {
let copy = editing_copy(&b64(&r));
assert_eq!(paint_outline(©), want, "{opts}");
}
- let path = tmp("gradients.ai");
+ let path = tmp(&scratch, "gradients.ai");
let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap();
let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
assert_eq!(files.len(), 3, "{r}");
diff --git a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs
index 86cb84cfcb..d3d8a988f8 100644
--- a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs
+++ b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs
@@ -59,7 +59,7 @@ fn live_texts(s: &Session) -> Vec {
#[test]
fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() {
let bytes = live_text_pdf();
- let dir = vectorcraft_testkit::temp_dir("pdf-place-live-text");
+ let dir = vectorcraft_testkit::TempDir::new("pdf-place-live-text");
let path = dir.join("text.pdf");
std::fs::write(&path, &bytes).unwrap();
for source in [json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}), json!({"path": path, "link": false})] {
@@ -71,7 +71,6 @@ fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() {
assert_eq!(live_texts(&s), ["Second page"], "{r}");
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
}
- std::fs::remove_dir_all(dir).unwrap();
}
#[test]
diff --git a/crates/engine/src/cmd/fileio/tests_readonly.rs b/crates/engine/src/cmd/fileio/tests_readonly.rs
index badf82a8cf..ae9762efc3 100644
--- a/crates/engine/src/cmd/fileio/tests_readonly.rs
+++ b/crates/engine/src/cmd/fileio/tests_readonly.rs
@@ -9,7 +9,7 @@ use crate::{EngineError, READ_ONLY_LOCATION, Session};
#[test]
fn readonly_error_is_typed_and_other_writers_keep_their_error() {
- let dir = vectorcraft_testkit::temp_dir("save-refusal");
+ let dir = vectorcraft_testkit::TempDir::new("save-refusal");
let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned();
for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] {
let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
@@ -26,7 +26,6 @@ fn readonly_error_is_typed_and_other_writers_keep_their_error() {
}
assert!(!std::path::Path::new(&path).exists());
}
- std::fs::remove_dir_all(dir).unwrap();
}
#[test]
diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs
index fd850bb741..9b41eea001 100644
--- a/crates/engine/src/cmd/gradient.rs
+++ b/crates/engine/src/cmd/gradient.rs
@@ -24,7 +24,7 @@ pub fn specs() -> Vec {
"Gradient",
["Window", "Gradient"],
None,
- "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)",
+ "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)",
has_doc,
edit_gradient
),
@@ -126,7 +126,11 @@ fn parse_kind(k: &str) -> Parsed {
fn method_param(p: &Value) -> Parsed> {
match p.get("method") {
None | Some(Value::Null) => Ok(None),
- Some(v) => v.as_str().and_then(GradientMethod::parse).map(Some).ok_or_else(|| "`method` must be classic or perceptual".into()),
+ Some(v) => v
+ .as_str()
+ .and_then(GradientMethod::parse)
+ .map(Some)
+ .ok_or_else(|| "`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise".into()),
}
}
diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs
index 29de2210ae..037033ea8a 100644
--- a/crates/engine/src/cmd/gradpresets.rs
+++ b/crates/engine/src/cmd/gradpresets.rs
@@ -215,7 +215,7 @@ fn apply(s: &mut Session, p: &Value) -> Result {
o.insert("stroke".into(), json!(stroke));
o.insert("kind".into(), json!(g.kind.label().to_lowercase()));
o.insert("stops".into(), stops_json(&g.stops));
- o.insert("method".into(), json!(g.method.label().to_lowercase()));
+ o.insert("method".into(), json!(g.method.id()));
// A freeform preset takes no spread (paint.editGradient refuses one).
if g.kind != vectorcraft_color::GradientKind::Freeform {
o.insert("spread".into(), json!(g.spread.label().to_lowercase()));
diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs
index baebad1e6c..b1a491424e 100644
--- a/crates/engine/src/cmd/graph/tests.rs
+++ b/crates/engine/src/cmd/graph/tests.rs
@@ -2478,7 +2478,7 @@ fn series_colours_survive_native_save_open_and_export() {
let second = group(s.doc().unwrap().doc.node(id).unwrap(), "2025").id;
s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#f00"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [second.0], "color": "#008080"})).unwrap();
- let dir = vectorcraft_testkit::temp_dir("graph-palette");
+ let dir = vectorcraft_testkit::TempDir::new("graph-palette");
let path = dir.join("palette.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
@@ -2892,7 +2892,8 @@ fn series_types_survive_save_and_open_and_older_files_have_none() {
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
- let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-kinds");
+ let path = dir.join("kinds.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]);
@@ -3020,7 +3021,8 @@ fn value_axis_options_survive_save_and_open_and_are_left_out_when_unset() {
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
- let path = vectorcraft_testkit::temp_dir("graph-axes").join("axes.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-axes");
+ let path = dir.join("axes.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
@@ -3222,7 +3224,8 @@ fn tick_and_label_options_survive_save_and_open_and_are_left_out_when_unset() {
&json!({"tickLength": "full", "tickMarks": 3, "categoryTickLength": "short", "ticksBetweenLabels": true, "suffix": "%"}),
)
.unwrap();
- let path = vectorcraft_testkit::temp_dir("graph-ticks").join("ticks.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-ticks");
+ let path = dir.join("ticks.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
@@ -3461,7 +3464,8 @@ fn designs_and_markers_survive_save_and_open() {
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": "Pill"})).unwrap();
- let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-designs");
+ let path = dir.join("designs.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"]));
@@ -3859,7 +3863,8 @@ fn column_designs_survive_save_and_open_and_follow_their_design() {
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series");
let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap();
assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}));
- let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-column-designs");
+ let path = dir.join("columns.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale };
@@ -4381,7 +4386,8 @@ fn legends_in_wedges_put_the_series_labels_inside_the_pie() {
s.execute("graph.setType", &json!({"legend": true})).unwrap();
assert!(!spec(&s, id).pie_legend_in_wedges);
assert!(s.execute("graph.setType", &json!({"pieLegend": "inside"})).is_err());
- let path = vectorcraft_testkit::temp_dir("graph-pie").join("pie.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-pie");
+ let path = dir.join("pie.vectorcraft");
s.execute("graph.setType", &json!({"pieLegend": "wedges", "piePosition": "stacked", "pieSort": "all"})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
@@ -4522,7 +4528,8 @@ fn the_legend_across_top_runs_in_rows_above_the_plot() {
assert!(ys.iter().all(|y| *y + 8.0 <= 100.0 - 8.0 + 1e-6), "{ys:?}");
let xs: Vec = (0..6).map(|i| swatch(&s, i).x1).collect();
assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}");
- let path = vectorcraft_testkit::temp_dir("graph-top").join("top.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-top");
+ let path = dir.join("top.vectorcraft");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
@@ -4641,7 +4648,8 @@ fn filled_lines_are_bands_in_the_series_colour() {
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#ff0000"})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2], [3], [2]]})).unwrap();
assert_eq!(series_parts(&s, id, 0)[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
- let path = vectorcraft_testkit::temp_dir("graph-filled").join("filled.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-filled");
+ let path = dir.join("filled.vectorcraft");
s.execute("graph.setType", &json!({"firstRowInFront": true, "firstColumnInFront": true})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
@@ -4768,7 +4776,8 @@ fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None);
s.execute("edit.redo", &json!({})).unwrap();
- let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-label-anti-alias");
+ let path = dir.join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong));
@@ -4811,7 +4820,8 @@ fn formatting_one_label_keeps_that_kind_of_label() {
assert_eq!(look("0").2, "#0000ff", "every value axis number");
assert_eq!(look("0").1, 9.0);
// Kept through save and open; nothing written when unset.
- let path = vectorcraft_testkit::temp_dir("graph-labels").join("labels.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-labels");
+ let path = dir.join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories.as_ref().unwrap().size, 14.0);
@@ -4980,7 +4990,8 @@ fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() {
let v = text_id(&s, col, "Axes", "4");
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap();
- let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft");
+ let dir = vectorcraft_testkit::TempDir::new("graph-label-paras");
+ let path = dir.join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs
index c7c82c7625..2a8d074617 100644
--- a/crates/engine/src/cmd/newdoc.rs
+++ b/crates/engine/src/cmd/newdoc.rs
@@ -63,7 +63,7 @@ pub fn specs() -> Vec {
"Edit Profile",
[],
None,
- "{name: a category of file.newPresets} open the category's New Document profile as a document to edit (File › Save keeps the changes for the next file.new); a category without a profile file gets the shipped profile written first → document.open's result",
+ "{name: a category of file.newPresets} open the category's New Document profile as a document to edit (File › Save keeps the changes for the next file.new); a category without a profile file gets the shipped profile written first; under automation roots it needs write and read access to the file, and an error names the file by its file name outside the read roots → document.open's result",
always,
edit_profile
),
@@ -72,7 +72,7 @@ pub fn specs() -> Vec {
"Reset Profile",
[],
None,
- "{name: a category of file.newPresets} delete the category's profile file, so the shipped profile (today's defaults) applies again; an open copy of it stays open → {name, reset: whether a file was deleted}",
+ "{name: a category of file.newPresets} delete the category's profile file, so the shipped profile (today's defaults) applies again; an open copy of it stays open; under automation roots it needs write access to the file, and an error names the file by its file name outside the read roots → {name, reset: whether a file was deleted}",
always,
reset_profile
),
@@ -999,10 +999,17 @@ fn list_profiles(s: &mut Session, _: &Value) -> Result {
Ok(json!({ "profiles": profiles, "folder": s.new_doc_profile_dir().and_then(shown) }))
}
+/// `msg`, an error about the profile file at `path`, as the request being served may see it: the
+/// file named whole inside the read roots, else by its file name.
+fn profile_error(msg: String, path: &str) -> super::EngineError {
+ super::fileio::naming_in_error(super::EngineError::Other(msg), path)
+}
+
/// The file of profile `name` for `cmd` to change, checked against the request's write access.
fn writable_profile(s: &Session, name: &str, cmd: &str) -> Result {
let path = s.profile_path(name).ok_or_else(|| bad(cmd, "New Document profiles need the preferences folder, which this run doesn't have"))?;
- crate::file_access::check_write(&path.to_string_lossy()).map_err(super::EngineError::Other)?;
+ let file = path.to_string_lossy();
+ crate::file_access::check_write(&file).map_err(|e| profile_error(e, &file))?;
Ok(path)
}
@@ -1012,13 +1019,13 @@ fn edit_profile(s: &mut Session, p: &Value) -> Result {
let file = writable_profile(s, name, C)?;
let path = file.to_string_lossy().to_string();
// document.open reads it next: a profile it can't open is not written either.
- crate::file_access::check_read(&path).map_err(super::EngineError::Other)?;
+ crate::file_access::check_read(&path).map_err(|e| profile_error(e, &path))?;
if !file.exists() {
let bytes = vectorcraft_format::save(&shipped_profile(s, name), false);
file.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| std::fs::write(&file, bytes))
- .map_err(|e| super::EngineError::Other(format!("{path}: {e}")))?;
+ .map_err(|e| profile_error(format!("{path}: {e}"), &path))?;
}
s.execute("document.open", &json!({ "path": path }))
}
@@ -1031,7 +1038,7 @@ fn reset_profile(s: &mut Session, p: &Value) -> Result {
let reset = match std::fs::remove_file(&file) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
- Err(e) => return Err(super::EngineError::Other(format!("{path}: {e}"))),
+ Err(e) => return Err(profile_error(format!("{path}: {e}"), &path)),
};
Ok(json!({ "name": name, "reset": reset }))
}
diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs
index 3a79656d6c..edab9cfe22 100644
--- a/crates/engine/src/cmd/paint.rs
+++ b/crates/engine/src/cmd/paint.rs
@@ -21,7 +21,7 @@ pub fn specs() -> Vec {
"Fill",
[],
None,
- "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
+ "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs
index c38854ae76..ddbaec22a2 100644
--- a/crates/engine/src/cmd/params.rs
+++ b/crates/engine/src/cmd/params.rs
@@ -773,9 +773,7 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> {
NO_SAVE_FORMAT
}
"document.export" | "document.exportSelection" | "document.serialize" => {
- // `richBlack` is Appearance of Black's setting for a raster export, and `params` an
- // exporter plug-in's own.
- keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]);
+ keys.extend(super::fileio::EXPORT_PARAMS);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
NO_FORMAT
}
diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs
index 22c1c0c929..d0713a243a 100644
--- a/crates/engine/src/cmd/swatch.rs
+++ b/crates/engine/src/cmd/swatch.rs
@@ -410,6 +410,8 @@ fn gradient_key(g: &Gradient) -> Option> {
let method = match g.method {
GradientMethod::Classic => 0,
GradientMethod::Perceptual => 1,
+ GradientMethod::HueClockwise => 2,
+ GradientMethod::HueCounterclockwise => 3,
};
Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::>()?))
}
diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs
index 0d472ef81d..297322fda0 100644
--- a/crates/engine/src/session/documents.rs
+++ b/crates/engine/src/session/documents.rs
@@ -404,11 +404,18 @@ impl Session {
true
}
+ /// Close the document in tab `index` (`file.close`). Closing a tab other than the current one
+ /// keeps the current document current, and its tool keeps its state: no document is switched
+ /// to. Closing the current document makes the tab now at `index` current, or the last tab when
+ /// it was the last one. False, changing nothing, when `index` is past the last document.
pub fn close_document(&mut self, index: usize) -> bool {
if index >= self.docs.len() {
return false;
}
- self.reset_tool_for_doc_switch();
+ let front = self.active().map(|d| d.uid).filter(|_| self.active != Some(index));
+ if front.is_none() {
+ self.reset_tool_for_doc_switch();
+ }
// Closed (saved or discarded): nothing left to recover.
let uid = self.docs[index].uid;
cmd::recovery::forget(self, uid);
@@ -416,6 +423,10 @@ impl Session {
if let Some(stash) = &mut self.batch_stash {
stash.push(old);
}
+ if let Some(uid) = front {
+ self.active = self.docs.iter().position(|d| d.uid == uid);
+ return true;
+ }
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
self.sync_front_composite_fonts();
self.sync_front_profiles();
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 6c231dc7e5..c260b42d90 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -47,6 +47,7 @@ mod dashalign;
mod dcs;
mod distort;
mod docsetup;
+mod document_close;
mod document_move;
mod draw2;
mod dynamic_symbols;
diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs
index b350341767..0cdc3312ff 100644
--- a/crates/engine/src/tests/arrowheads.rs
+++ b/crates/engine/src/tests/arrowheads.rs
@@ -284,6 +284,87 @@ fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() {
assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null);
}
+/// Automation roots that grant reading `read` alone.
+fn read_roots(read: &[&std::path::Path]) -> Arc {
+ crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap()
+}
+
+#[test]
+fn an_agent_outside_the_arrowheads_files_folder_sees_its_file_name_alone_and_cannot_reload_it() {
+ let dir = folder("roots-list");
+ let (prefs, other) = (dir.join("prefs"), dir.join("other"));
+ std::fs::create_dir_all(&prefs).unwrap();
+ std::fs::create_dir_all(&other).unwrap();
+ let file = prefs.join(arrowheads::FILE_NAME);
+ std::fs::write(&file, b"not a drawing").unwrap();
+ let (file, prefs_text) = (file.to_string_lossy().to_string(), prefs.to_string_lossy().to_string());
+ let mut s = Session::new();
+ s.attach_arrowheads_file(file.clone());
+ let (outside, inside) = (read_roots(&[&other]), read_roots(&[&prefs]));
+ let list = crate::file_access::confine(Some(&outside), || s.execute("stroke.arrowheads.list", &json!({}))).unwrap();
+ assert_eq!(list["file"], serde_json::Value::Null, "{list}");
+ let error = list["error"].as_str().unwrap();
+ assert!(error.starts_with(arrowheads::FILE_NAME) && !error.contains(&prefs_text), "{error}");
+ let list = crate::file_access::confine(Some(&inside), || s.execute("stroke.arrowheads.list", &json!({}))).unwrap();
+ assert_eq!(list["file"].as_str(), Some(file.as_str()));
+ assert!(list["error"].as_str().unwrap().starts_with(&file), "{list}");
+ // A good file now: an agent outside the folder cannot have it read.
+ std::fs::write(&file, vectorcraft_format::save_file(&arrowheads::starter())).unwrap();
+ let e = crate::file_access::confine(Some(&outside), || s.execute("stroke.arrowheads.reload", &json!({}))).unwrap_err().to_string();
+ assert!(e.contains("outside the read root") && e.ends_with(arrowheads::FILE_NAME) && !e.contains(&prefs_text), "{e}");
+ let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
+ assert!(list["arrowheads"].as_array().unwrap().is_empty() && list["error"].is_string(), "not read again: {list}");
+ let r = crate::file_access::confine(Some(&inside), || s.execute("stroke.arrowheads.reload", &json!({}))).unwrap();
+ assert_eq!(r["count"], 1);
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn an_agent_edits_the_arrowheads_file_only_inside_its_read_roots() {
+ let dir = folder("roots-edit");
+ let (prefs, other) = (dir.join("prefs"), dir.join("other"));
+ std::fs::create_dir_all(&other).unwrap();
+ let file = prefs.join(arrowheads::FILE_NAME).to_string_lossy().to_string();
+ let prefs_text = prefs.to_string_lossy().to_string();
+ let (mut s, _) = session_with_line();
+ s.attach_arrowheads_file(file.clone());
+ let line_doc = s.doc().unwrap().uid;
+ let outside = read_roots(&[&other]);
+ let edit = |s: &mut Session, roots: &Arc| {
+ crate::file_access::confine(Some(roots), || s.execute("stroke.arrowheads.edit", &json!({})))
+ };
+ // Refused before the folder or the file is made, or anything is opened.
+ let e = edit(&mut s, &outside).unwrap_err().to_string();
+ assert!(e.ends_with(arrowheads::FILE_NAME) && !e.contains(&prefs_text), "{e}");
+ assert!(!prefs.exists(), "no folder made");
+ assert_eq!((s.documents().len(), s.doc().unwrap().uid), (1, line_doc));
+ // From the Stroke panel menu (no roots), the file is made and opened.
+ assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], true);
+ assert_eq!(s.documents().len(), 2);
+ // Open already: still not brought to the front for an agent outside the folder.
+ s.execute("document.activate", &json!({"index": 0})).unwrap();
+ assert!(edit(&mut s, &outside).is_err());
+ assert_eq!((s.documents().len(), s.doc().unwrap().uid), (2, line_doc));
+ // Inside the read roots: opened under them, with its path.
+ let inside = read_roots(&[&prefs]);
+ let mut agent = Session::new();
+ agent.attach_arrowheads_file(file.clone());
+ let r = edit(&mut agent, &inside).unwrap();
+ assert_eq!((r["path"].as_str(), &r["created"]), (Some(file.as_str()), &json!(false)));
+ let st = agent.doc().unwrap();
+ assert_eq!(st.path.as_deref(), Some(file.as_str()));
+ assert!(st.opened_by.as_ref().is_some_and(|r| Arc::ptr_eq(r, &inside)), "opened under the agent's roots");
+ // A missing file inside the roots is still made first.
+ let fresh = dir.join("fresh").join(arrowheads::FILE_NAME).to_string_lossy().to_string();
+ let mut agent = Session::new();
+ agent.attach_arrowheads_file(fresh.clone());
+ let r = edit(&mut agent, &read_roots(&[&dir])).unwrap();
+ assert_eq!(r["created"], true);
+ assert!(std::path::Path::new(&fresh).is_file());
+ assert_eq!(agent.doc().unwrap().path.as_deref(), Some(fresh.as_str()));
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
#[test]
fn the_heads_listed_after_a_save_are_read_from_the_file_and_editing_keeps_it() {
let dir = folder("saved");
diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs
index 21cbf678eb..6eeace1073 100644
--- a/crates/engine/src/tests/build.rs
+++ b/crates/engine/src/tests/build.rs
@@ -840,7 +840,7 @@ fn shape_builder_applies_a_merge_before_what_comes_next() {
}),
];
let _proof = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
- let dir = vectorcraft_testkit::temp_dir("shape-builder-merge-first");
+ let dir = vectorcraft_testkit::TempDir::new("shape-builder-merge-first");
for (what, then) in next {
let mut s = session();
if what == "Select All Instances" {
diff --git a/crates/engine/src/tests/cancel.rs b/crates/engine/src/tests/cancel.rs
index 8237707a10..9a044ead89 100644
--- a/crates/engine/src/tests/cancel.rs
+++ b/crates/engine/src/tests/cancel.rs
@@ -22,7 +22,8 @@ fn revision(s: &Session) -> u64 {
#[test]
fn a_pdf_export_cancelled_between_pages_stops_writes_nothing_and_changes_nothing() {
- let path = vectorcraft_testkit::temp_dir("cancel-pdf").join("out.pdf");
+ let dir = vectorcraft_testkit::TempDir::new("cancel-pdf");
+ let path = dir.join("out.pdf");
std::fs::remove_file(&path).ok();
let mut s = session(5);
let before = (revision(&s), s.doc().unwrap().history.undo.len());
@@ -66,7 +67,7 @@ fn a_cancelled_flattening_export_stops_before_the_pages() {
#[test]
fn raster_exports_and_svg_stop_when_cancelled_too() {
- let dir = vectorcraft_testkit::temp_dir("cancel-formats");
+ let dir = vectorcraft_testkit::TempDir::new("cancel-formats");
let mut s = session(3);
let token = CancelToken::new();
token.cancel();
diff --git a/crates/engine/src/tests/compare.rs b/crates/engine/src/tests/compare.rs
index 3bd4446d61..3a6c66e2f5 100644
--- a/crates/engine/src/tests/compare.rs
+++ b/crates/engine/src/tests/compare.rs
@@ -36,7 +36,7 @@ fn error(s: &mut Session, p: Value) -> String {
#[test]
fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() {
- let dir = vectorcraft_testkit::temp_dir("compare-moved");
+ let dir = vectorcraft_testkit::TempDir::new("compare-moved");
let png = path_in(&dir, "art.png");
let (mut s, id) = session();
export(&mut s, &png);
@@ -54,12 +54,11 @@ fn an_export_compares_equal_and_a_moved_square_counts_the_pixels_it_changed() {
assert_eq!(r["rmse"].as_f64(), Some(rmse), "{r}");
// Above every difference, nothing is over.
assert_eq!(compare(&mut s, json!({"path": png, "threshold": 1}))["over"], json!(0));
- let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_image_and_the_render_are_flattened_on_the_background() {
- let dir = vectorcraft_testkit::temp_dir("compare-background");
+ let dir = vectorcraft_testkit::TempDir::new("compare-background");
let png = path_in(&dir, "clear.png");
// Transparent everywhere, with red in the color channels that the alpha hides.
image::RgbaImage::from_pixel(80, 40, image::Rgba([255, 0, 0, 0])).save(&png).unwrap();
@@ -81,12 +80,11 @@ fn the_image_and_the_render_are_flattened_on_the_background() {
assert_eq!((r["rmse"].as_f64(), r["over"].as_u64()), (Some(rmse(255.0)), Some(1600)), "{r}");
// A threshold just above 128/255 leaves the gray differences out.
assert_eq!(compare(&mut s, json!({"path": png, "background": "#808080", "threshold": 0.51}))["over"], json!(0));
- let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() {
- let dir = vectorcraft_testkit::temp_dir("compare-size");
+ let dir = vectorcraft_testkit::TempDir::new("compare-size");
let (mut s, _) = session();
let square = path_in(&dir, "square.png");
image::RgbaImage::from_pixel(30, 30, image::Rgba([255; 4])).save(&square).unwrap();
@@ -96,12 +94,11 @@ fn an_image_of_other_proportions_is_refused_and_one_pixel_is_allowed() {
image::RgbaImage::from_pixel(80, 41, image::Rgba([255; 4])).save(&taller).unwrap();
let r = compare(&mut s, json!({"path": taller}));
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["total"].as_u64()), (Some(80), Some(41), Some(3280)), "{r}");
- let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_export_whose_height_was_rounded_compares_equal() {
- let dir = vectorcraft_testkit::temp_dir("compare-rounded");
+ let dir = vectorcraft_testkit::TempDir::new("compare-rounded");
let png = path_in(&dir, "art.png");
// 20.25 pt at 2 pixels a point is 40.5 rows, which the export rounds to 41.
let mut s = Session::new();
@@ -112,7 +109,6 @@ fn an_export_whose_height_was_rounded_compares_equal() {
export(&mut s, &png);
let r = compare(&mut s, json!({"path": png}));
assert_eq!(r, json!({"width": 80, "height": 41, "rmse": 0.0, "over": 0, "total": 3280, "threshold": 0.08}));
- let _ = std::fs::remove_dir_all(dir);
}
#[test]
@@ -151,7 +147,7 @@ fn a_linked_image_is_drawn_from_its_file_as_a_png_export_draws_it() {
#[test]
fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does() {
- let dir = vectorcraft_testkit::temp_dir("compare-warnings");
+ let dir = vectorcraft_testkit::TempDir::new("compare-warnings");
let png = path_in(&dir, "art.png");
let (mut s, _) = session();
export(&mut s, &png);
@@ -167,12 +163,11 @@ fn an_image_that_cannot_be_decoded_and_a_missing_font_warn_as_a_png_export_does(
assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{r}");
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("Nonexistent Sans Probe")), "{r}");
assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("doesn't fit its frame")), "{r}");
- let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn diff_path_writes_each_pixels_differences() {
- let dir = vectorcraft_testkit::temp_dir("compare-diff");
+ let dir = vectorcraft_testkit::TempDir::new("compare-diff");
let png = path_in(&dir, "art.png");
let diff = path_in(&dir, "diff.png");
let (mut s, id) = session();
@@ -188,12 +183,11 @@ fn diff_path_writes_each_pixels_differences() {
}
let changed = img.pixels().filter(|p| p.0[0] != 0).count();
assert_eq!(changed as u64, r["over"].as_u64().unwrap());
- let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn missing_unreadable_and_malformed_inputs_are_errors() {
- let dir = vectorcraft_testkit::temp_dir("compare-errors");
+ let dir = vectorcraft_testkit::TempDir::new("compare-errors");
let (mut s, _) = session();
let png = path_in(&dir, "art.png");
export(&mut s, &png);
@@ -216,14 +210,13 @@ fn missing_unreadable_and_malformed_inputs_are_errors() {
let e = error(&mut s, p.clone());
assert!(e.contains(want), "{p}: {e}");
}
- let _ = std::fs::remove_dir_all(dir);
}
/// A planar TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a strip
/// each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() {
- let dir = vectorcraft_testkit::temp_dir("compare-strips");
+ let dir = vectorcraft_testkit::TempDir::new("compare-strips");
let (mut s, _) = session();
let plane = |v: u16| vec![v; 6];
let channels = [plane(200), plane(40), plane(10), plane(0), plane(90)];
@@ -247,12 +240,11 @@ fn a_tiff_with_more_strips_than_can_be_counted_is_too_large() {
std::fs::write(&tif, &file).unwrap();
let e = error(&mut s, json!({"path": tif}));
assert_eq!(e, "invalid parameters for `document.compare`: an image of 3 × 2147483649 pixels is too large to draw");
- let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_image_and_the_difference_image_stay_inside_the_automation_roots() {
- let dir = vectorcraft_testkit::temp_dir("compare-roots");
+ let dir = vectorcraft_testkit::TempDir::new("compare-roots");
for d in ["in", "out", "elsewhere"] {
std::fs::create_dir_all(dir.join(d)).unwrap();
}
@@ -272,5 +264,4 @@ fn the_image_and_the_difference_image_stay_inside_the_automation_roots() {
assert!(!std::path::Path::new(&stray).exists());
let kept = path_in(&dir.join("out"), "diff.png");
assert_eq!(run(json!({"path": inside, "diffPath": kept})).unwrap()["diffPath"], json!(kept));
- let _ = std::fs::remove_dir_all(dir);
}
diff --git a/crates/engine/src/tests/containers.rs b/crates/engine/src/tests/containers.rs
index 437f932724..42cebea2ad 100644
--- a/crates/engine/src/tests/containers.rs
+++ b/crates/engine/src/tests/containers.rs
@@ -312,7 +312,7 @@ fn an_images_stroke_with_outline_object_follows_its_opaque_area() {
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 0}));
assert_eq!([shade(&s, 61, 100), shade(&s, 64, 100)], ["white", "black"]);
// Relinked to an opaque image, the stroke follows the frame 3 pt out: it shows from 43 to 50.
- let dir = vectorcraft_testkit::temp_dir("outline-object-relink");
+ let dir = vectorcraft_testkit::TempDir::new("outline-object-relink");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("opaque.png");
let mut png = vec![];
diff --git a/crates/engine/src/tests/css.rs b/crates/engine/src/tests/css.rs
index 1ce5b09e41..39fb6a5bc2 100644
--- a/crates/engine/src/tests/css.rs
+++ b/crates/engine/src/tests/css.rs
@@ -57,6 +57,28 @@ fn type_gives_font_properties_and_gradients_css_gradients() {
assert!(text.contains("background-image: linear-gradient(90deg, "), "{text}");
}
+/// CSS gradients mix linearly: a gradient mixed around the hue wheel is written through the stops
+/// on its way around the wheel, so red to blue lists green one way and magenta the other.
+#[test]
+fn gradients_mixed_around_the_hue_wheel_give_css_the_stops_on_their_way() {
+ let mut s = session();
+ run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50}));
+ let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
+ let gradient = |s: &mut Session, method: &str| {
+ run(s, "paint.setFill", json!({"gradient": {"kind": "linear", "method": method, "stops": stops}}));
+ let text = css(&run(s, "css.selection", json!({}))).to_string();
+ text.lines().find(|l| l.contains("linear-gradient(")).unwrap_or_else(|| panic!("a gradient in {text}")).to_string()
+ };
+ let classic = gradient(&mut s, "classic");
+ assert_eq!(classic.matches('%').count(), 2, "{classic}");
+ for (method, middle, other) in [("hue-counterclockwise", "#00ff00 50%", "#ff00ff"), ("hue-clockwise", "#ff00ff 50%", "#00ff00")] {
+ let line = gradient(&mut s, method);
+ assert!(line.matches('%').count() > 2, "{method}: {line}");
+ assert!(line.contains("#ff0000 0%") && line.contains("#0000ff 100%"), "{method}: {line}");
+ assert!(line.contains(middle) && !line.contains(other), "{method}: {line}");
+ }
+}
+
#[test]
fn generate_covers_the_document_and_an_empty_selection_has_none() {
let mut s = session();
diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs
index 5c806884d8..b780f14c09 100644
--- a/crates/engine/src/tests/dcs.rs
+++ b/crates/engine/src/tests/dcs.rs
@@ -3,8 +3,6 @@
//! the Links panel's Show DCS Transparency Interactions (`links.list` with `dcsTransparency`).
//! The files are made from scratch as http://www.telegraphics.net/doc/DCS.txt describes them.
-use std::path::PathBuf;
-
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Node, NodeKind};
@@ -17,12 +15,9 @@ fn session() -> Session {
s
}
-/// A fresh scratch folder for one test.
-fn temp_dir(name: &str) -> PathBuf {
- let d = std::env::temp_dir().join(format!("vc-dcs-{name}-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&d);
- std::fs::create_dir_all(&d).unwrap();
- d
+/// A fresh scratch folder for one test, removed when dropped.
+fn temp_dir(name: &str) -> vectorcraft_testkit::TempDir {
+ vectorcraft_testkit::TempDir::new(&format!("dcs-{name}"))
}
/// A plate program: one 2 × 2 grayscale image (`gray`, row by row) on a 20 × 10 pt page.
diff --git a/crates/engine/src/tests/document_close.rs b/crates/engine/src/tests/document_close.rs
new file mode 100644
index 0000000000..a1b106d365
--- /dev/null
+++ b/crates/engine/src/tests/document_close.rs
@@ -0,0 +1,91 @@
+//! `file.close {index}`: closing a document tab other than the current one keeps the current
+//! document current, and closing the current one makes its neighbor current.
+
+use serde_json::json;
+
+use crate::*;
+
+/// A session with documents A, B, C and D, in that order, D current.
+fn four() -> Session {
+ let mut s = Session::new();
+ for name in ["A", "B", "C", "D"] {
+ s.execute("file.new", &json!({"name": name, "created": null})).unwrap();
+ }
+ s
+}
+
+fn order(s: &Session) -> String {
+ s.documents().iter().map(DocState::title).collect::>().join("")
+}
+
+fn current(s: &Session) -> String {
+ s.active().map(DocState::title).unwrap_or_default()
+}
+
+fn close(s: &mut Session, index: usize) {
+ s.execute("file.close", &json!({"index": index})).unwrap_or_else(|e| panic!("file.close {index}: {e}"));
+}
+
+#[test]
+fn closing_a_tab_before_the_current_one_keeps_the_current_document() {
+ let mut s = four();
+ close(&mut s, 0);
+ assert_eq!((order(&s), current(&s)), ("BCD".into(), "D".into()));
+ assert_eq!(s.active_index(), Some(2));
+ assert!(s.set_active(1));
+ close(&mut s, 0);
+ assert_eq!((order(&s), current(&s)), ("CD".into(), "C".into()));
+ assert_eq!(s.active_index(), Some(0));
+}
+
+#[test]
+fn closing_a_tab_after_the_current_one_keeps_the_current_document() {
+ let mut s = four();
+ assert!(s.set_active(1));
+ close(&mut s, 3);
+ assert_eq!((order(&s), current(&s)), ("ABC".into(), "B".into()));
+ close(&mut s, 2);
+ assert_eq!((order(&s), current(&s)), ("AB".into(), "B".into()));
+ assert_eq!(s.active_index(), Some(1));
+}
+
+#[test]
+fn closing_the_current_tab_makes_its_neighbor_current() {
+ let mut s = four();
+ assert!(s.set_active(1));
+ close(&mut s, 1);
+ assert_eq!((order(&s), current(&s)), ("ACD".into(), "C".into()), "the tab that took its place");
+ assert!(s.set_active(2));
+ close(&mut s, 2);
+ assert_eq!((order(&s), current(&s)), ("AC".into(), "C".into()), "the last tab: the one before it");
+ s.execute("file.close", &json!({})).unwrap();
+ assert_eq!((order(&s), current(&s)), ("A".into(), "A".into()), "without an index, the current one closes");
+ close(&mut s, 0);
+ assert!(s.active().is_none());
+ assert!(s.execute("file.close", &json!({"index": 0})).is_err(), "no document left");
+}
+
+#[test]
+fn closing_another_tab_keeps_the_tool_and_the_documents_edits() {
+ let mut s = four();
+ s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
+ let resets = s.tool_resets;
+ let nodes = s.doc().unwrap().doc.node_count();
+ let history = super::history(&s);
+ close(&mut s, 1);
+ assert_eq!(s.tool_resets, resets, "no document was switched to, so the tool is not reset");
+ assert_eq!(super::history(&s), history);
+ assert_eq!(s.doc().unwrap().doc.node_count(), nodes);
+ assert!(s.doc().unwrap().is_dirty(), "the document keeps its unsaved edit");
+ close(&mut s, 2);
+ assert_ne!(s.tool_resets, resets, "closing the current document resets the tool");
+}
+
+#[test]
+fn a_bad_index_closes_nothing() {
+ let mut s = four();
+ assert!(s.set_active(2));
+ assert!(s.execute("file.close", &json!({"index": 4})).is_err());
+ assert!(!s.close_document(4));
+ assert_eq!((order(&s), current(&s)), ("ABCD".into(), "C".into()));
+}
diff --git a/crates/engine/src/tests/fontfiles.rs b/crates/engine/src/tests/fontfiles.rs
index 8bef8733b5..52a8ff08a3 100644
--- a/crates/engine/src/tests/fontfiles.rs
+++ b/crates/engine/src/tests/fontfiles.rs
@@ -14,12 +14,12 @@ use crate::cmd::findfiles::{self, Limits, Rules, Visitor};
use crate::cmd::fontfiles::{self, FontSearch, MAX_FONT_FILE, Sought, copy_into};
use crate::{EngineError, Session};
-/// A fresh temporary folder (canonical, as a search reports it).
-fn folder(tag: &str) -> PathBuf {
- let dir = vectorcraft_testkit::temp_dir(&format!("fontfiles-{tag}"));
- let _ = std::fs::remove_dir_all(&dir);
- std::fs::create_dir_all(&dir).unwrap();
- findfiles::plain(std::fs::canonicalize(&dir).unwrap())
+/// A fresh temporary folder, removed when the first part is dropped, and its path (canonical, as a
+/// search reports it).
+fn folder(tag: &str) -> (vectorcraft_testkit::TempDir, PathBuf) {
+ let dir = vectorcraft_testkit::TempDir::new(&format!("fontfiles-{tag}"));
+ let path = findfiles::plain(std::fs::canonicalize(&*dir).unwrap());
+ (dir, path)
}
/// A session whose folder searches may go anywhere in `root`, on two threads.
@@ -103,7 +103,7 @@ fn missing_fonts_are_listed_symbols_included() {
/// listed finds the other fonts' files.
#[test]
fn an_overlong_font_name_leaves_the_other_fonts_searchable() {
- let root = folder("long-name");
+ let (_dir, root) = folder("long-name");
std::fs::write(root.join("Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap();
let mut s = session_for(&root);
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
@@ -120,7 +120,7 @@ fn an_overlong_font_name_leaves_the_other_fonts_searchable() {
/// apps' folders, such as the attachments a mail app keeps in the home folder's Library.
#[test]
fn the_fonts_folder_next_to_a_document_in_another_apps_folder_isnt_offered() {
- let root = folder("next-to");
+ let (_dir, root) = folder("next-to");
let home = root.join("Users/me");
let mut s = Session::new();
s.search_rules = Some(Rules { home: Some(home.clone()), ..Rules::default() });
@@ -140,7 +140,7 @@ fn the_fonts_folder_next_to_a_document_in_another_apps_folder_isnt_offered() {
#[cfg(unix)]
#[test]
fn the_fonts_folder_next_to_a_document_is_an_absolute_path() {
- let root = folder("relative");
+ let (_dir, root) = folder("relative");
std::fs::create_dir_all(root.join("pkg/Fonts")).unwrap();
let cwd = std::env::current_dir().unwrap();
let up = "../".repeat(cwd.components().count().saturating_sub(1));
@@ -160,7 +160,7 @@ fn the_fonts_folder_next_to_a_document_is_an_absolute_path() {
#[test]
fn a_search_finds_a_missing_fonts_file_by_its_names() {
- let root = folder("find");
+ let (_dir, root) = folder("find");
std::fs::create_dir_all(root.join("x/y")).unwrap();
std::fs::write(root.join("x/y/Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap();
std::fs::write(root.join("other.ttf"), bundled("SourceSerif4-Regular.ttf")).unwrap();
@@ -199,7 +199,7 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() {
/// when they are inside them.
#[test]
fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
- let root = folder("read-roots");
+ let (_dir, root) = folder("read-roots");
let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3");
let (home, wide) = (root.join("home"), root.join("lrf-wide"));
for dir in [&home, &wide] {
@@ -228,12 +228,11 @@ fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide")));
let r = ask(&mut s, &narrow, json!({}));
assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}");
- let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn junk_search_params_never_fail_internally() {
- let root = folder("junk");
+ let (_dir, root) = folder("junk");
let mut s = session_for(&root);
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
text(&mut s, 20.0, "No Such Font Family", "Regular");
@@ -297,7 +296,7 @@ impl Visitor for Blocked {
#[test]
fn stop_ends_only_the_search_it_names() {
- let root = folder("stop");
+ let (_dir, root) = folder("stop");
std::fs::write(root.join("a.ttf"), b"font").unwrap();
let mut s = session_for(&root);
let (tx, rx) = mpsc::channel();
@@ -330,7 +329,7 @@ fn adding_fonts_needs_vectorcraft_s_fonts_folder() {
#[test]
fn copying_never_replaces_a_file() {
- let root = folder("copy");
+ let (_dir, root) = folder("copy");
let (src, dest) = (root.join("src"), root.join("dest"));
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dest).unwrap();
@@ -376,12 +375,11 @@ fn copying_never_replaces_a_file() {
assert_eq!(reasons[..2], ["larger than 256 MB", "not a file"]);
assert!(r.copied.is_empty() && reasons.len() == 3, "{r:?}");
assert!(!dest.join("huge.otf").exists() && !dest.join("folder.otf").exists());
- let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn only_font_files_are_copied() {
- let root = folder("allowed");
+ let (_dir, root) = folder("allowed");
let (src, dest) = (root.join("src"), root.join("dest"));
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dest).unwrap();
@@ -395,13 +393,12 @@ fn only_font_files_are_copied() {
let not_a_font = |n: &str| (src.join(n), "not a font file".to_string());
assert_eq!(r.skipped, [not_a_font("notes.txt"), not_a_font("Script")]);
assert!(!dest.join("notes.txt").exists() && !dest.join("Script").exists());
- let _ = std::fs::remove_dir_all(&root);
}
/// A search reads no font file larger than Add Fonts copies, and finds a smaller copy of the font.
#[test]
fn a_search_skips_font_files_too_large_to_add() {
- let root = folder("too-large");
+ let (_dir, root) = folder("too-large");
let (big, small) = (root.join("big").join("Sizeme.otf"), root.join("small").join("Sizeme.otf"));
let font = vectorcraft_testkit::fonts::renamed("Sizeme Sans 3");
for path in [&big, &small] {
@@ -415,7 +412,6 @@ fn a_search_skips_font_files_too_large_to_add() {
s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Sizeme Sans 3"}, {"family": "No Such Sans"}]})).unwrap();
let r = finished(&mut s);
assert_eq!(r["fonts"][0]["files"], json!([small]), "{r}");
- let _ = std::fs::remove_dir_all(&root);
}
/// A suitcase font: a file without an extension or with `.suit`, empty but for the fonts in its
@@ -424,7 +420,7 @@ fn a_search_skips_font_files_too_large_to_add() {
#[test]
fn suitcase_fonts_are_found_and_copied_with_their_fork() {
use vectorcraft_testkit::fonts::{renamed, suitcase_fork};
- let root = folder("suitcase");
+ let (_dir, root) = folder("suitcase");
let (src, dest) = (root.join("src"), root.join("dest"));
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::create_dir_all(&dest).unwrap();
@@ -467,5 +463,4 @@ fn suitcase_fonts_are_found_and_copied_with_their_fork() {
// The font scan reads the copies.
let db = vectorcraft_text::FontDb::with_font_dirs(vec![dest.clone()]);
assert_eq!((db.styles("Suitme Sans 3"), db.styles("Other Sans 33")), (vec!["Regular".to_string()], vec!["Regular".to_string()]));
- let _ = std::fs::remove_dir_all(&root);
}
diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs
index a2aea6341d..b8c8bcd325 100644
--- a/crates/engine/src/tests/gradient.rs
+++ b/crates/engine/src/tests/gradient.rs
@@ -511,7 +511,7 @@ fn an_outlined_stroke_leaves_dither_behind() {
/// The Gradient panel's Method (Illustrator 30): `method` set and cleared through the gradient
/// commands, on fills and strokes, kept by the params, swatches and a native save and load;
-/// anything but classic or perceptual is refused without editing.
+/// anything but the four method names is refused without editing.
#[test]
fn method_is_a_gradient_option_that_saves_and_loads() {
use vectorcraft_color::GradientMethod;
@@ -548,7 +548,7 @@ fn method_is_a_gradient_option_that_saves_and_loads() {
let before = node(&s, id).appearance.clone();
for bad in [json!("smooth"), json!(1), json!(true)] {
let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string();
- assert!(e.contains("`method` must be classic or perceptual"), "{e}");
+ assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{e}");
assert!(s.execute("paint.setFill", &json!({"gradient": {"method": bad}})).is_err());
}
assert_eq!(node(&s, id).appearance, before);
@@ -566,6 +566,76 @@ fn method_is_a_gradient_option_that_saves_and_loads() {
assert!(back.gradient.dither && back.gradient.method == GradientMethod::Perceptual);
}
+/// The hue methods: `method` hue-clockwise and hue-counterclockwise set through the gradient
+/// commands on fills and strokes, as one undo step, kept by the params, a swatch, a saved preset
+/// and a native save and load, and written with or without the hyphen.
+#[test]
+fn the_hue_methods_are_set_saved_and_loaded() {
+ use vectorcraft_color::GradientMethod;
+ let mut s = session();
+ let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
+ let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
+ s.execute("paint.setFill", &json!({"gradient": {"stops": stops}})).unwrap();
+ for (name, method, middle) in
+ [("hue-clockwise", GradientMethod::HueClockwise, "#ff00ff"), ("hue-counterclockwise", GradientMethod::HueCounterclockwise, "#00ff00")]
+ {
+ let undo = s.doc().unwrap().history.undo.len();
+ s.execute("paint.editGradient", &json!({"method": name})).unwrap();
+ let g = fill_gradient(&s, id);
+ assert_eq!(g.gradient.method, method);
+ assert_eq!(g.gradient.stops.len(), 2, "{name}: the stops stay as they were");
+ assert_eq!(g.gradient.sample(0.5).0.to_hex(), middle, "{name}: halfway around the wheel");
+ assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{name}: one undo step");
+ // The params carry it, so a swatch, a preset and a copied paint keep it.
+ let p = vectorcraft_tools::params::gradient_params(&g);
+ assert_eq!(p["method"], json!(name));
+ let mut q = gradient_param(&g);
+ q["name"] = json!(format!("Wheel {name}"));
+ s.execute("swatch.new", &q).unwrap();
+ let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch(&format!("Wheel {name}")).map(|w| w.paint.clone()) else {
+ panic!("a gradient swatch")
+ };
+ assert_eq!(sw.gradient.method, method, "{name}: the swatch");
+ s.execute("gradient.presets.save", &json!({"name": format!("Preset {name}")})).unwrap();
+ s.execute("paint.editGradient", &json!({"method": "classic"})).unwrap();
+ s.execute("gradient.presets.apply", &json!({"name": format!("Preset {name}")})).unwrap();
+ assert_eq!(fill_gradient(&s, id).gradient.method, method, "{name}: the preset");
+ // Saved and opened again.
+ let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
+ let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
+ assert_eq!(saved.gradient.method, method, "{name}: the native file keeps the method");
+ }
+ // Strokes take it too, and the name reads without the hyphen and in any case.
+ for (name, method) in [("hue-clockwise", GradientMethod::HueClockwise), ("hue-counterclockwise", GradientMethod::HueCounterclockwise)] {
+ for written in [name.replace('-', " "), name.replace('-', "").to_uppercase()] {
+ s.execute("paint.setStroke", &json!({"gradient": {"method": "classic"}})).unwrap();
+ s.execute("paint.editGradient", &json!({"method": written, "stroke": true})).unwrap();
+ let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") };
+ assert_eq!(st.gradient.method, method, "{written}");
+ }
+ }
+ // Reverse gives the same ramp the other way round: the hue method turns with the stops, so
+ // the middle keeps its color, unless the edit names a method too.
+ s.execute("paint.editGradient", &json!({"method": "hue-clockwise", "stroke": false})).unwrap();
+ let before = fill_gradient(&s, id).gradient;
+ s.execute("paint.editGradient", &json!({"reverse": true, "stroke": false})).unwrap();
+ let after = fill_gradient(&s, id).gradient;
+ assert_eq!(after.method, GradientMethod::HueCounterclockwise);
+ assert_eq!((after.stops[0].color.to_hex(), after.stops[1].color.to_hex()), ("#0000ff".to_string(), "#ff0000".to_string()));
+ for t in [0.25, 0.5, 0.75] {
+ assert_eq!(after.sample(t).0.to_hex(), before.sample(1.0 - t).0.to_hex(), "reversed at {t}");
+ }
+ s.execute("paint.editGradient", &json!({"reverse": true, "method": "hue-counterclockwise", "stroke": false})).unwrap();
+ assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::HueCounterclockwise, "a method in the edit wins");
+ // Half a name is refused, nothing changed.
+ let before = node(&s, id).appearance.clone();
+ for bad in ["hue", "clockwise", "hue-clock"] {
+ let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string();
+ assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{bad}: {e}");
+ }
+ assert_eq!(node(&s, id).appearance, before);
+}
+
/// A conic gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params
/// agents and swatches read, and the native format.
#[test]
diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs
index 5eb66ec6c8..159f0604e5 100644
--- a/crates/engine/src/tests/journal.rs
+++ b/crates/engine/src/tests/journal.rs
@@ -275,7 +275,8 @@ fn a_batch_fit_selection_replays_but_an_action_uses_the_current_selection() {
#[test]
fn a_save_records_its_date_and_replays_it() {
- let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned();
+ let dir = vectorcraft_testkit::TempDir::new("journal-save");
+ let path = dir.join("a.vectorcraft").to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", &json!({"created": null})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
@@ -290,7 +291,6 @@ fn a_save_records_its_date_and_replays_it() {
assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(2_000_000_000));
assert!(s.execute("document.save", &json!({"path": path, "modified": "today"})).is_err());
// Save As stamps the same way; Save a Copy leaves the document's dates (and records none).
- let dir = vectorcraft_testkit::temp_dir("journal-save");
s.execute("file.saveAs", &json!({"path": dir.join("b.vectorcraft").to_string_lossy(), "modified": 2_000_000_100})).unwrap();
assert_eq!(s.journal.last().unwrap().1["modified"], json!(2_000_000_100));
s.execute("file.saveCopy", &json!({"path": dir.join("c.vectorcraft").to_string_lossy()})).unwrap();
@@ -316,7 +316,8 @@ fn replayed(s: &Session) -> Session {
#[test]
fn a_save_in_a_batch_records_its_date_and_replays_it() {
- let path = vectorcraft_testkit::temp_dir("journal-batch").join("a.vectorcraft").to_string_lossy().into_owned();
+ let dir = vectorcraft_testkit::TempDir::new("journal-batch");
+ let path = dir.join("a.vectorcraft").to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", &json!({"created": 1_000_000_000})).unwrap();
let rect = json!({"x": 10, "y": 10, "width": 50, "height": 40});
@@ -698,7 +699,8 @@ fn a_failed_batch_closes_the_documents_it_opened_and_restores_the_ones_it_closed
#[test]
fn a_failed_batch_brings_back_a_document_it_reverted() {
- let path = vectorcraft_testkit::temp_dir("journal-batch").join("revert.vectorcraft").to_string_lossy().into_owned();
+ let dir = vectorcraft_testkit::TempDir::new("journal-batch");
+ let path = dir.join("revert.vectorcraft").to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", &json!({"created": null})).unwrap();
s.execute("document.save", &json!({"path": path, "modified": null})).unwrap();
diff --git a/crates/engine/src/tests/library.rs b/crates/engine/src/tests/library.rs
index d7927ec6bf..c2dccc7bb5 100644
--- a/crates/engine/src/tests/library.rs
+++ b/crates/engine/src/tests/library.rs
@@ -816,7 +816,7 @@ fn style_order_of(s: &mut Session) -> Vec {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_library_item_rename_is_saved_or_taken_back() {
- let dir = vectorcraft_testkit::temp_dir("library-rename-item");
+ let dir = vectorcraft_testkit::TempDir::new("library-rename-item");
let folder = dir.to_string_lossy().to_string();
let mut s = session();
s.libraries.set_dir(Some(folder.clone()));
@@ -836,7 +836,6 @@ fn a_library_item_rename_is_saved_or_taken_back() {
run(&mut s, "library.renameItem", json!({"kind": "charStyle", "item": "Text", "name": " Text "}));
assert!(s.execute("library.renameItem", &json!({"kind": "charStyle", "item": "Text", "name": "Copy"})).is_err());
assert_eq!(style_order_of(&mut s), ["Text"], "taken back");
- let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
@@ -1056,7 +1055,8 @@ fn a_new_item_is_added_where_it_is_dropped() {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_add_that_cannot_be_placed_makes_no_library() {
- let folder = vectorcraft_testkit::temp_dir("library-add-unplaced").to_string_lossy().to_string();
+ let dir = vectorcraft_testkit::TempDir::new("library-add-unplaced");
+ let folder = dir.to_string_lossy().to_string();
let mut s = session();
s.libraries.set_dir(Some(folder.clone()));
for bad in [json!({"kind": "fillColor", "color": "#808080", "group": "Nope"}), json!({"kind": "fillColor", "color": "#808080", "after": "Nope"})]
@@ -1163,7 +1163,7 @@ fn a_library_of_one_repeated_name_imports_numbered() {
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_library_is_exported_to_a_file_and_imported_from_it() {
- let dir = vectorcraft_testkit::temp_dir("library-export");
+ let dir = vectorcraft_testkit::TempDir::new("library-export");
let path = dir.join("Brand.vclibrary").to_string_lossy().to_string();
let (mut s, g, ..) = brand();
run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}]}));
diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs
index 7f70d24ff0..9ed3db2724 100644
--- a/crates/engine/src/tests/linked_stops.rs
+++ b/crates/engine/src/tests/linked_stops.rs
@@ -416,6 +416,34 @@ fn spot_gradients_drawn_as_images_warn() {
assert!(!spot_warning(&warnings), "{warnings:?}");
}
+/// A spot gradient mixed around the hue wheel is an image in process colors with the warning,
+/// where the same stops mixed by Perceptual are a Separation or DeviceN shading: the colors
+/// between its stops are not tints or mixes of the stops' inks.
+#[test]
+fn hue_wheel_spot_gradients_export_as_images_and_warn() {
+ let (mut s, _) = setup();
+ run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 1, "m": 0, "y": 1, "k": 0}, "spot": true}));
+ let spot_warning = |w: &[String]| w.iter().any(|w| w.contains("spot colors in gradients exported as images"));
+ let ink_to_leaf = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "swatch": "Leaf"}]});
+ for method in ["hue-clockwise", "hue-counterclockwise"] {
+ for (what, stops) in [("Ink → paper", ink_to_white(80.0)), ("Ink → Leaf", ink_to_leaf.clone())] {
+ for kind in ["linear", "radial", "conic", "diamond"] {
+ run(&mut s, "paint.editGradient", with(stops.clone(), json!({"method": method, "kind": kind})));
+ let (pdf, warnings) = pdf_of(&s);
+ assert!(pdf.contains("/Subtype/Image"), "{method} {kind} {what}: an image");
+ assert!(!pdf.contains("/Separation") && !pdf.contains("/DeviceN"), "{method} {kind} {what}: no spot ink shading");
+ assert!(spot_warning(&warnings), "{method} {kind} {what}: {warnings:?}");
+ }
+ }
+ // Without spot inks: an image, without the warning.
+ let red_blue = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "kind": "linear"});
+ run(&mut s, "paint.editGradient", with(red_blue, json!({"method": method})));
+ let (pdf, warnings) = pdf_of(&s);
+ assert!(pdf.contains("/Subtype/Image"), "{method}: a process gradient is an image");
+ assert!(!spot_warning(&warnings), "{method}: {warnings:?}");
+ }
+}
+
#[test]
fn freeform_gradients_without_points_warn_about_spot_stops() {
let (mut s, id) = setup();
diff --git a/crates/engine/src/tests/linkfiles.rs b/crates/engine/src/tests/linkfiles.rs
index ef9127042a..6f64a2fe33 100644
--- a/crates/engine/src/tests/linkfiles.rs
+++ b/crates/engine/src/tests/linkfiles.rs
@@ -14,12 +14,12 @@ use crate::cmd::linkfiles::{self, LinkSearch, Sought};
use crate::tests_links::{BLUE, RED, place, png};
use crate::{EngineError, Session};
-/// A fresh temporary folder (canonical, as a search reports it).
-fn folder(tag: &str) -> PathBuf {
- let dir = vectorcraft_testkit::temp_dir(&format!("linkfiles-{tag}"));
- let _ = std::fs::remove_dir_all(&dir);
- std::fs::create_dir_all(&dir).unwrap();
- findfiles::plain(std::fs::canonicalize(&dir).unwrap())
+/// A fresh temporary folder, removed when the first part is dropped, and its path (canonical, as a
+/// search reports it).
+fn folder(tag: &str) -> (vectorcraft_testkit::TempDir, PathBuf) {
+ let dir = vectorcraft_testkit::TempDir::new(&format!("linkfiles-{tag}"));
+ let path = findfiles::plain(std::fs::canonicalize(&*dir).unwrap());
+ (dir, path)
}
/// Write `bytes` to the file at `path`, making its folders.
@@ -80,7 +80,7 @@ fn found(r: &Value, name: &str, root: &Path) -> Vec<(String, String, bool)> {
/// innermost out up to the first that differs.
#[test]
fn files_found_are_ranked_by_what_the_link_saved() {
- let root = folder("rank");
+ let (_dir, root) = folder("rank");
let mut s = session_for(&root);
let (logo, photo) = (png(60, 40, RED), png(50, 30, BLUE));
write(&root.join("orig/logo.png"), &logo);
@@ -127,7 +127,7 @@ fn files_found_are_ranked_by_what_the_link_saved() {
/// the files found are ranked by the folder names they share with that path.
#[test]
fn a_classic_mac_path_is_found_by_its_name_and_folders() {
- let root = folder("classic");
+ let (_dir, root) = folder("classic");
let mut s = session_for(&root);
let photo = png(50, 30, BLUE);
write(&root.join("orig/photo.png"), &photo);
@@ -158,7 +158,7 @@ fn a_classic_mac_path_is_found_by_its_name_and_folders() {
#[test]
fn parent_components_in_a_saved_path_are_resolved_before_ranking() {
for (tag, saved) in [("classic-up", "Old Disk:Projects:Art::photo.png"), ("slash-up", "/old/Projects/Art/../photo.png")] {
- let root = folder(tag);
+ let (_dir, root) = folder(tag);
let mut s = session_for(&root);
let photo = png(50, 30, BLUE);
write(&root.join("orig/photo.png"), &photo);
@@ -188,7 +188,7 @@ fn parent_components_in_a_saved_path_are_resolved_before_ranking() {
/// files, whatever their status.
#[test]
fn a_search_looks_for_the_missing_files_or_the_objects_given() {
- let root = folder("which");
+ let (_dir, root) = folder("which");
let mut s = session_for(&root);
write(&root.join("orig/gone.png"), &png(40, 30, RED));
write(&root.join("orig/here.png"), &png(40, 30, BLUE));
@@ -225,7 +225,7 @@ fn a_search_looks_for_the_missing_files_or_the_objects_given() {
/// other contents leaves it to search every folder.
#[test]
fn a_search_ends_once_every_linked_file_has_its_same_file() {
- let root = folder("early");
+ let (_dir, root) = folder("early");
let mut s = session_for(&root);
s.search_threads = Some(1);
let bytes = png(40, 30, RED);
@@ -265,7 +265,7 @@ impl Visitor for Blocked {
#[test]
fn stop_ends_only_the_search_it_names() {
- let root = folder("stop");
+ let (_dir, root) = folder("stop");
std::fs::write(root.join("a.png"), b"png").unwrap();
let mut s = session_for(&root);
let (tx, rx) = mpsc::channel();
@@ -290,7 +290,7 @@ fn stop_ends_only_the_search_it_names() {
/// roots, the replies are as before.
#[test]
fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
- let root = folder("read-roots");
+ let (_dir, root) = folder("read-roots");
let mut s = session_for(&root);
let photo = png(40, 30, RED);
write(&root.join("lrf-away/photo.png"), &photo);
@@ -343,14 +343,13 @@ fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
let r = ask(&mut s, &narrow, json!({}));
assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}");
hidden(&r);
- let _ = std::fs::remove_dir_all(&root);
}
/// A link's saved path relative to the document is shown to a confined caller when it leads to a
/// file inside its read roots, and left out when `..` leads out of them.
#[test]
fn a_relative_link_path_is_judged_from_the_documents_folder() {
- let root = folder("read-roots-relative");
+ let (_dir, root) = folder("read-roots-relative");
let mut s = session_for(&root);
let home = root.join("home");
write(&root.join("lrf-away/photo.png"), &png(40, 30, RED));
@@ -370,14 +369,13 @@ fn a_relative_link_path_is_judged_from_the_documents_folder() {
assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}");
finished(&mut s);
}
- let _ = std::fs::remove_dir_all(&root);
}
/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link`
/// null), as `links.info` leaves it out, and keeps the other fields.
#[test]
fn image_info_leaves_out_a_link_outside_the_read_roots() {
- let root = folder("read-roots-info");
+ let (_dir, root) = folder("read-roots-info");
let mut s = session_for(&root);
let pic = root.join("lrf-away/photo.png");
write(&pic, &png(40, 30, RED));
@@ -391,12 +389,11 @@ fn image_info_leaves_out_a_link_outside_the_read_roots() {
let info = ask(&mut s, "links.info");
assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}");
assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic));
- let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn relinking_with_copy_puts_the_file_in_the_documents_folder() {
- let root = folder("copy");
+ let (_dir, root) = folder("copy");
let mut s = session_for(&root);
write(&root.join("orig/photo.png"), &png(40, 30, RED));
let id = placed(&mut s, &root.join("orig/photo.png"));
@@ -443,14 +440,13 @@ fn relinking_with_copy_puts_the_file_in_the_documents_folder() {
let image = placed(&mut unsaved, &found);
let r = unsaved.execute("links.relink", &json!({"ids": [image], "path": found, "copy": true}));
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg.contains("save the document first")), "{r:?}");
- let _ = std::fs::remove_dir_all(&root);
}
/// Copy takes images only: with copy, a call that includes a placed document fails and copies
/// nothing; without it, the document is relinked where its file is.
#[test]
fn copy_refuses_placed_documents() {
- let root = folder("copy-placed");
+ let (_dir, root) = folder("copy-placed");
let mut s = session_for(&root);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
let badge = root.join("badge.vectorcraft");
@@ -471,7 +467,7 @@ fn copy_refuses_placed_documents() {
#[cfg(unix)]
#[test]
fn a_copy_stays_inside_the_write_root() {
- let root = folder("roots");
+ let (_dir, root) = folder("roots");
let mut s = session_for(&root);
write(&root.join("orig/photo.png"), &png(40, 30, RED));
let id = placed(&mut s, &root.join("orig/photo.png"));
@@ -487,14 +483,13 @@ fn a_copy_stays_inside_the_write_root() {
assert!(matches!(&r, Err(EngineError::Other(m)) if m.contains("outside the write root")), "{r:?}");
assert!(!root.join("doc/photo 2.png").exists());
assert_eq!(std::fs::read(allowed.join("photo.png")).unwrap(), b"kept as it is");
- let _ = std::fs::remove_dir_all(&root);
}
/// An SVG's linked files are read on the importer's own thread (it needs a deep stack): the roots
/// in force on the thread that opens the SVG hold there too.
#[test]
fn an_svg_reads_its_links_within_the_read_root() {
- let root = folder("svg-roots");
+ let (_dir, root) = folder("svg-roots");
for dir in ["in", "out"] {
write(&root.join(dir).join("dot.png"), &png(4, 4, RED));
}
@@ -513,12 +508,11 @@ fn an_svg_reads_its_links_within_the_read_root() {
assert_eq!(import("in"), inside, "inside the read root: read as before");
assert_ne!(import("out"), outside, "outside the read root: not read");
});
- let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn junk_search_params_never_fail_internally() {
- let root = folder("junk");
+ let (_dir, root) = folder("junk");
let mut s = session_for(&root);
write(&root.join("orig/a.png"), &png(4, 4, RED));
let id = placed(&mut s, &root.join("orig/a.png"));
diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs
index deaf60b64f..209d22a42a 100644
--- a/crates/engine/src/tests/newdoc.rs
+++ b/crates/engine/src/tests/newdoc.rs
@@ -631,6 +631,39 @@ fn the_profile_follows_the_preset_access_and_reset() {
let _ = std::fs::remove_dir_all(&dir);
}
+#[test]
+fn profile_errors_name_the_file_by_its_name_outside_the_read_roots() {
+ let (mut s, dir) = with_profiles("errors");
+ let elsewhere = dir.with_extension("elsewhere");
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::create_dir_all(&elsewhere).unwrap();
+ let whole = dir.join("Print.vectorcraft").to_string_lossy().to_string();
+ let run = |s: &mut Session, roots: &std::sync::Arc, cmd: &str| {
+ crate::file_access::confine(Some(roots), || s.execute(cmd, &json!({"name": "Print"}))).unwrap_err().to_string()
+ };
+ // No write access: edit and reset are refused before anything is written or deleted.
+ let read_only = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[]).unwrap().unwrap();
+ for cmd in ["file.newProfiles.edit", "file.newProfiles.reset"] {
+ let e = run(&mut s, &read_only, cmd);
+ assert!(e.ends_with(": Print.vectorcraft") && !e.contains(&whole), "{cmd}: {e}");
+ }
+ // Write access without read access: edit is refused, and nothing is written.
+ let write_only = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[dir.as_path()]).unwrap().unwrap();
+ let e = run(&mut s, &write_only, "file.newProfiles.edit");
+ assert!(e.contains("outside the read root") && e.ends_with(": Print.vectorcraft") && !e.contains(&whole), "{e}");
+ assert!(!dir.join("Print.vectorcraft").exists());
+ // A file reset cannot delete (a folder in its place) is named by its file name too.
+ std::fs::create_dir_all(dir.join("Print.vectorcraft")).unwrap();
+ let e = run(&mut s, &write_only, "file.newProfiles.reset");
+ assert!(e.starts_with("Print.vectorcraft: ") && !e.contains(&whole), "{e}");
+ // Inside the read roots the error names the whole path.
+ let inside = crate::file_access::AutomationRoots::new(&[dir.as_path()], &[dir.as_path()]).unwrap().unwrap();
+ let e = run(&mut s, &inside, "file.newProfiles.reset");
+ assert!(e.starts_with(&format!("{whole}: ")), "{e}");
+ let _ = std::fs::remove_dir_all(&elsewhere);
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
/// The new document's links find their files from the profile's file, as a document opened from
/// that file does; a document without a profile has no such file.
#[test]
diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs
index 3808d06797..37127f686a 100644
--- a/crates/engine/src/tests/prefs.rs
+++ b/crates/engine/src/tests/prefs.rs
@@ -756,6 +756,9 @@ fn precise_cursors_replace_every_pen_state() {
Cursor::LivePaintBucket,
Cursor::LivePaintBucketFace,
Cursor::LivePaintBucketEdge,
+ Cursor::LivePaintSelectFace,
+ Cursor::LivePaintSelectEdge,
+ Cursor::LivePaintSelectOutside,
] {
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
}
diff --git a/crates/engine/src/tests/shortcutset.rs b/crates/engine/src/tests/shortcutset.rs
index 8f216b8c00..fb5292e243 100644
--- a/crates/engine/src/tests/shortcutset.rs
+++ b/crates/engine/src/tests/shortcutset.rs
@@ -51,7 +51,7 @@ fn entries_keep_their_sections_and_the_command_counts_codes() {
assert_eq!(r["codes"]["modifiers"], json!([{"value": 64, "count": 2}, {"value": 80, "count": 1}]));
assert_eq!((r["unread"].as_array().unwrap().len(), r["truncated"].as_bool()), (0, Some(false)), "{r}");
// The same from a file, and from its bytes.
- let dir = vectorcraft_testkit::temp_dir("shortcutset-read");
+ let dir = vectorcraft_testkit::TempDir::new("shortcutset-read");
let path = dir.join("Studio Keys.kys");
std::fs::write(&path, text).unwrap();
let mut s = Session::new();
@@ -59,7 +59,6 @@ fn entries_keep_their_sections_and_the_command_counts_codes() {
assert_eq!((r["set"].as_str(), r["entries"].as_array().unwrap().len()), (Some("Studio Keys"), 3));
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
assert_eq!(s.execute("shortcutSet.read", &json!({ "dataBase64": b64 })).unwrap()["entries"].as_array().unwrap().len(), 3);
- let _ = std::fs::remove_dir_all(dir);
}
#[test]
@@ -124,7 +123,7 @@ fn caps_nesting_entries_and_names() {
#[test]
fn big_files_are_read_up_to_the_cap() {
let text = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(5 << 20));
- let dir = vectorcraft_testkit::temp_dir("shortcutset-big");
+ let dir = vectorcraft_testkit::TempDir::new("shortcutset-big");
let path = dir.join("Big.kys");
std::fs::write(&path, &text).unwrap();
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
@@ -145,7 +144,6 @@ fn big_files_are_read_up_to_the_cap() {
assert_eq!(fits.len(), (4 << 20) + 3);
std::fs::write(&path, &fits).unwrap();
assert_eq!(s.execute("shortcutSet.read", &json!({ "path": path.to_string_lossy() })).unwrap()["truncated"], false);
- let _ = std::fs::remove_dir_all(dir);
}
fn matched(s: &mut Session, p: Value) -> Vec {
diff --git a/crates/engine/src/tests/spelling.rs b/crates/engine/src/tests/spelling.rs
index 16d7f01993..dd10df1597 100644
--- a/crates/engine/src/tests/spelling.rs
+++ b/crates/engine/src/tests/spelling.rs
@@ -161,7 +161,7 @@ fn a_changed_word_replaces_the_old_one() {
#[test]
fn the_custom_dictionary_file_is_read_and_kept_up_to_date() {
let _one_at_a_time = dictionary_lock();
- let dir = vectorcraft_testkit::temp_dir("spelling-dictionary");
+ let dir = vectorcraft_testkit::TempDir::new("spelling-dictionary");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "kqzvwx\nnot one word\n\n").unwrap();
let mut s = session();
@@ -224,7 +224,7 @@ fn in_dictionary(s: &mut Session, word: &str) -> bool {
#[test]
fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() {
let _one_at_a_time = dictionary_lock();
- let dir = vectorcraft_testkit::temp_dir("spelling-unreadable");
+ let dir = vectorcraft_testkit::TempDir::new("spelling-unreadable");
// A folder where the file should be can't be read as one; a file past what craft-proof reads
// would lose its other words if written back.
let folder = dir.join("Custom Dictionary.txt");
@@ -248,7 +248,7 @@ fn a_change_that_cant_be_saved_is_taken_back() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
run(&mut s, "text.spelling.addWord", json!({"word": "wkqzvx"}));
- let dir = vectorcraft_testkit::temp_dir("spelling-rollback");
+ let dir = vectorcraft_testkit::TempDir::new("spelling-rollback");
let folder = dir.join("Custom Dictionary.txt");
std::fs::create_dir_all(&folder).unwrap();
let mut failing = session();
@@ -266,7 +266,7 @@ fn a_change_that_cant_be_saved_is_taken_back() {
fn a_dictionary_edit_is_saved_whole_or_not_at_all() {
let _one_at_a_time = dictionary_lock();
const EDIT: &str = "text.spelling.editDictionary";
- let dir = vectorcraft_testkit::temp_dir("spelling-edit");
+ let dir = vectorcraft_testkit::TempDir::new("spelling-edit");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "pqzvka\npqzvkb\n").unwrap();
let mut s = session();
@@ -302,7 +302,7 @@ fn a_dictionary_edit_is_saved_whole_or_not_at_all() {
fn a_word_another_window_saved_stays_when_this_save_fails() {
let _one_at_a_time = dictionary_lock();
use std::os::unix::fs::PermissionsExt;
- let dir = vectorcraft_testkit::temp_dir("spelling-readonly");
+ let dir = vectorcraft_testkit::TempDir::new("spelling-readonly");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "").unwrap();
let mut s = session();
diff --git a/crates/engine/src/tests/store.rs b/crates/engine/src/tests/store.rs
index b6ac7b0201..21967473bb 100644
--- a/crates/engine/src/tests/store.rs
+++ b/crates/engine/src/tests/store.rs
@@ -7,10 +7,8 @@ use vectorcraft_color::cms;
use crate::Session;
use crate::store::{MAX_PRESET_FILE, file_stem, holds};
-fn fresh_dir(tag: &str) -> std::path::PathBuf {
- let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}"));
- let _ = std::fs::remove_dir_all(&dir);
- dir
+fn fresh_dir(tag: &str) -> vectorcraft_testkit::TempDir {
+ vectorcraft_testkit::TempDir::new(&format!("store-{tag}"))
}
fn saved_names(s: &mut Session) -> Vec {
@@ -20,7 +18,8 @@ fn saved_names(s: &mut Session) -> Vec {
#[test]
fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow() {
- let dir = fresh_dir("presets").join("PDF Presets");
+ let root = fresh_dir("presets");
+ let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Press", "description": "for the printer", "preset": "Press Quality"})).unwrap();
@@ -42,7 +41,8 @@ fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow()
#[test]
fn a_rename_that_cannot_be_written_keeps_the_preset_under_its_old_name() {
- let dir = fresh_dir("rename-fails").join("PDF Presets");
+ let root = fresh_dir("rename-fails");
+ let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
@@ -59,7 +59,8 @@ fn a_rename_that_cannot_be_written_keeps_the_preset_under_its_old_name() {
#[test]
fn a_rename_that_only_changes_case_keeps_one_file() {
- let dir = fresh_dir("rename-case").join("PDF Presets");
+ let root = fresh_dir("rename-case");
+ let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
@@ -80,7 +81,8 @@ fn file_names(dir: &std::path::Path) -> Vec {
#[cfg(windows)]
#[test]
fn a_rename_that_only_changes_case_and_cannot_be_written_keeps_the_old_file() {
- let dir = fresh_dir("rename-case-fails").join("PDF Presets");
+ let root = fresh_dir("rename-case-fails");
+ let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
@@ -103,7 +105,8 @@ fn a_rename_that_only_changes_case_and_cannot_be_written_keeps_the_old_file() {
#[cfg(not(any(windows, target_vendor = "apple")))]
#[test]
fn a_rename_that_only_changes_case_and_cannot_be_moved_keeps_the_old_preset() {
- let dir = fresh_dir("rename-case-blocked").join("PDF Presets");
+ let root = fresh_dir("rename-case-blocked");
+ let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
@@ -118,7 +121,8 @@ fn a_rename_that_only_changes_case_and_cannot_be_moved_keeps_the_old_preset() {
#[test]
fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() {
- let dir = fresh_dir("seed").join("PDF Presets");
+ let root = fresh_dir("seed");
+ let dir = root.join("PDF Presets");
let mut a = Session::new();
a.execute("pdf.preset.save", &json!({"name": "From Preferences"})).unwrap();
a.attach_pdf_preset_store(dir.clone());
@@ -155,7 +159,8 @@ fn a_write_reads_the_file_it_replaces_only_one_byte_past_the_new_length() {
#[test]
fn a_saved_preset_replaces_an_oversized_file_in_its_place() {
- let dir = fresh_dir("oversized-put").join("PDF Presets");
+ let root = fresh_dir("oversized-put");
+ let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
@@ -171,7 +176,8 @@ fn a_saved_preset_replaces_an_oversized_file_in_its_place() {
#[test]
fn damaged_unsafe_and_oversized_preset_files_are_skipped() {
- let dir = fresh_dir("damaged").join("PDF Presets");
+ let root = fresh_dir("damaged");
+ let dir = root.join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Good"})).unwrap();
@@ -214,7 +220,8 @@ fn icc_named(name: &str) -> Vec {
#[test]
fn stored_profiles_register_again_and_removing_one_removes_its_copy() {
- let dir = fresh_dir("profiles").join("Color Profiles");
+ let root = fresh_dir("profiles");
+ let dir = root.join("Color Profiles");
std::fs::create_dir_all(&dir).unwrap();
let name = "StoreRGB-1";
let file = format!("{}.icc", file_stem(name));
@@ -229,7 +236,8 @@ fn stored_profiles_register_again_and_removing_one_removes_its_copy() {
// A profile loaded from elsewhere is copied in, whatever the file is called.
let other = "StoreRGB-2";
- let loose = vectorcraft_testkit::temp_dir("store-loose").join("whatever.icm");
+ let loose_dir = vectorcraft_testkit::TempDir::new("store-loose");
+ let loose = loose_dir.join("whatever.icm");
std::fs::write(&loose, icc_named(other)).unwrap();
let r = s.execute("color.loadProfile", &json!({"path": loose.to_str().unwrap()})).unwrap();
assert_eq!((r["name"].as_str(), r["remembered"].as_bool()), (Some(other), Some(true)));
diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs
index b380a4e06b..445ce38ea7 100644
--- a/crates/engine/src/tests/swatchlib.rs
+++ b/crates/engine/src/tests/swatchlib.rs
@@ -130,11 +130,9 @@ fn reset_defaults_restores_missing_swatches_or_replaces_them() {
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#abcdef").unwrap()), "the default fill is unlinked too");
}
-/// A fresh scratch folder for one test.
-fn temp_dir(name: &str) -> std::path::PathBuf {
- let d = std::env::temp_dir().join(format!("vc-swatchlib-{name}-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&d);
- d
+/// A fresh scratch folder for one test, removed when the returned value is dropped.
+fn temp_dir(name: &str) -> vectorcraft_testkit::TempDir {
+ vectorcraft_testkit::TempDir::new(&format!("swatchlib-{name}"))
}
#[test]
diff --git a/crates/engine/src/tests/webexport.rs b/crates/engine/src/tests/webexport.rs
index bdd474612f..cf427f8174 100644
--- a/crates/engine/src/tests/webexport.rs
+++ b/crates/engine/src/tests/webexport.rs
@@ -294,7 +294,7 @@ fn files_named_after_the_title_follow_windows_naming_rules() {
#[test]
fn files_are_written_beside_the_path() {
- let dir = vectorcraft_testkit::temp_dir("webexport");
+ let dir = vectorcraft_testkit::TempDir::new("webexport");
let mut s = two_colours();
let path = dir.join("art.gif").to_string_lossy().to_string();
let r = run(&mut s, "document.exportForWeb", json!({"path": path}));
@@ -324,7 +324,7 @@ fn an_export_says_when_an_image_could_not_be_decoded() {
let r = run(&mut s, "document.exportForWeb.preview", json!({"format": format}));
assert!(undecoded(&r), "{format} preview: {}", r["warnings"]);
}
- let dir = vectorcraft_testkit::temp_dir("webexport-undecoded");
+ let dir = vectorcraft_testkit::TempDir::new("webexport-undecoded");
let page = dir.join("page.html").to_string_lossy().to_string();
let r = run(&mut s, "document.exportForWeb", json!({"path": page, "output": "html"}));
assert!(undecoded(&r), "{r}");
diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs
index 8e3830c81c..7de9793525 100644
--- a/crates/engine/src/tests/xform.rs
+++ b/crates/engine/src/tests/xform.rs
@@ -600,6 +600,35 @@ fn free_transform_distort_mode_via_pointer() {
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
}
+/// With Constrain on, a Free Distort corner keeps to the diagonal through the opposite corner, in
+/// one undo step that undoes and redoes whole.
+#[test]
+fn free_transform_constrained_distort_keeps_the_corner_on_its_diagonal() {
+ let mut s = session();
+ let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
+ let v = ViewInfo::default();
+ s.select_tool("freeTransform", v).unwrap();
+ s.set_tool_option("mode", &json!("distort"));
+ s.set_tool_option("constrain", &json!(true));
+ let before = undo_len(&s);
+ s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 200.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 230.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 340.0, 260.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Up, 340.0, 260.0), v).unwrap();
+ let pts = |s: &Session| -> Vec { s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect() };
+ let has_all = |pts: &[Point], want: [(f64, f64); 4]| want.iter().all(|&(x, y)| pts.iter().any(|q| q.distance(Point::new(x, y)) < 1e-6));
+ // (40, 60) along the diagonal (200, 100) is 0.28 of it, (56, 28).
+ let distorted = [(100.0, 100.0), (300.0, 100.0), (356.0, 228.0), (100.0, 200.0)];
+ let original = [(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)];
+ assert!(has_all(&pts(&s), distorted), "{:?}", pts(&s));
+ assert_eq!(undo_len(&s), before + 1);
+ assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert!(has_all(&pts(&s), original), "{:?}", pts(&s));
+ s.execute("edit.redo", &json!({})).unwrap();
+ assert!(has_all(&pts(&s), distorted), "{:?}", pts(&s));
+}
+
#[test]
fn free_transform_cmd_held_once_a_corner_drag_started_distorts_in_one_step() {
let mut s = session();
diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs
index fe56377d4e..e8b3ab0906 100644
--- a/crates/engine/tests/color_settings.rs
+++ b/crates/engine/tests/color_settings.rs
@@ -653,7 +653,7 @@ fn loading_the_working_rgb_profile_again_takes_effect_at_once() {
bytes
};
let name = "Reload RGB";
- let dir = vectorcraft_testkit::temp_dir("reload-working-rgb");
+ let dir = vectorcraft_testkit::TempDir::new("reload-working-rgb");
let load = |s: &mut Session, builtin: &str| {
let path = dir.join(format!("{}.icc", builtin.len()));
std::fs::write(&path, renamed(builtin, name)).unwrap();
@@ -734,11 +734,9 @@ impl Drop for ProofReset {
}
}
-/// A folder of its own for the test `tag`, empty.
-fn fresh_dir(tag: &str) -> std::path::PathBuf {
- let dir = vectorcraft_testkit::temp_dir(&format!("color-settings-{tag}"));
- let _ = std::fs::remove_dir_all(&dir);
- dir
+/// A folder of its own for the test `tag`, empty, removed when dropped.
+fn fresh_dir(tag: &str) -> vectorcraft_testkit::TempDir {
+ vectorcraft_testkit::TempDir::new(&format!("color-settings-{tag}"))
}
/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a
@@ -785,7 +783,8 @@ fn color_settings_come_back_in_the_next_session() {
let root = fresh_dir("color-settings");
let (profiles, file) = (root.join("Color Profiles"), root.join("Color Settings.json"));
let loaded = "StoreRGB-6";
- let icc = vectorcraft_testkit::temp_dir("store-color-settings").join("loaded.icc");
+ let icc_dir = vectorcraft_testkit::TempDir::new("store-color-settings");
+ let icc = icc_dir.join("loaded.icc");
let _reset = ColorSettingsReset { profile: Some(loaded), paths: vec![root.clone(), icc.clone()] };
std::fs::write(&icc, icc_named(loaded)).unwrap();
diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs
index 6577754de0..d88d99665f 100644
--- a/crates/engine/tests/command_sweep.rs
+++ b/crates/engine/tests/command_sweep.rs
@@ -12,10 +12,10 @@ use vectorcraft_testkit::invariants::{check_all, check_session, doc_json};
use vectorcraft_testkit::strategies::{command_param_keys, junk_params_for_command, junk_values, param_keys, safe_value_for_command};
/// Commands that replace/close the active document or touch the filesystem are fine to call, but
-/// their file and folder params are redirected into a temp dir (`safe_value_for_command`, which
-/// `junk_params_for_command` applies, by the keys the command catalog names as files and folders).
-fn safe_path() -> String {
- vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string()
+/// their file and folder params are redirected into the test's temp dir `dir` (`safe_value_for_command`,
+/// which `junk_params_for_command` applies, by the keys the command catalog names as files and folders).
+fn safe_path(dir: &std::path::Path) -> String {
+ dir.join("out.bin").to_string_lossy().to_string()
}
/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as
@@ -219,7 +219,8 @@ fn junk_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec {
#[test]
fn every_command_with_junk_params() {
- let path = safe_path();
+ let dir = vectorcraft_testkit::TempDir::new("sweep");
+ let path = safe_path(&dir);
let mut failures = vec![];
let mut calls = 0usize;
for fx in Fixture::ALL {
@@ -311,8 +312,8 @@ fn names_location(spec: Option<&vectorcraft_engine::CommandSpec>, key: &str) ->
/// command alone in at least one case ([`junk_cases`]).
#[test]
fn sweep_cases_name_files_only_inside_the_temp_folder() {
- let path = safe_path();
- let dir = vectorcraft_testkit::temp_dir("sweep");
+ let dir = vectorcraft_testkit::TempDir::new("sweep");
+ let path = safe_path(&dir);
let mut unsent = vec![];
let mut outside = std::collections::BTreeSet::new();
for c in command_specs() {
@@ -344,8 +345,8 @@ fn sweep_cases_name_files_only_inside_the_temp_folder() {
/// `paths: [{ids, path}]`, each with a string in the temp folder, and a folder key a folder there.
#[test]
fn sweep_cases_send_the_file_keys_of_the_command_catalog() {
- let path = safe_path();
- let dir = vectorcraft_testkit::temp_dir("sweep");
+ let dir = vectorcraft_testkit::TempDir::new("sweep");
+ let path = safe_path(&dir);
let spec = |id: &str| command_specs().iter().find(|c| c.id == id).unwrap();
let inside = |v: &Value| v.as_str().is_some_and(|s| std::path::Path::new(s).starts_with(&dir));
for (id, key) in [
@@ -381,7 +382,8 @@ fn sweep_cases_send_the_file_keys_of_the_command_catalog() {
/// document as it was, still applies after it.
#[test]
fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
- let path = safe_path();
+ let dir = vectorcraft_testkit::TempDir::new("sweep");
+ let path = safe_path(&dir);
let mut failures = vec![];
for fx in [Fixture::Single, Fixture::Multi] {
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
@@ -1127,7 +1129,8 @@ fn serializable_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec Value {
#[test]
fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() {
- let root = vectorcraft_testkit::temp_dir("font-folder");
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(root.join("Downloads/Deep")).unwrap();
- let root = plain(std::fs::canonicalize(&root).unwrap());
+ let dir = vectorcraft_testkit::TempDir::new("font-folder");
+ std::fs::create_dir_all(dir.join("Downloads/Deep")).unwrap();
+ let root = plain(std::fs::canonicalize(&*dir).unwrap());
let fonts = root.join("App Fonts");
vectorcraft_text::set_app_font_dir(fonts.clone());
assert!(vectorcraft_text::system_font_dirs().contains(&fonts));
@@ -85,5 +84,4 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() {
// Only files the last search found are copied.
let r = s.execute("text.addFontFiles", &json!({"files": [root.join("other.ttf")]}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("the last text.findFontFiles search")), "{r:?}");
- let _ = std::fs::remove_dir_all(&root);
}
diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs
index 37f8c63acf..04e7094f9e 100644
--- a/crates/engine/tests/import_fuzz.rs
+++ b/crates/engine/tests/import_fuzz.rs
@@ -6142,7 +6142,8 @@ fn findme_wanted() -> vectorcraft_text::WantedFonts {
/// Whether reading `bytes` as a font file a search reads panics.
fn search_reads(what: &str, bytes: &[u8]) -> Result, TestCaseError> {
- let path = vectorcraft_testkit::temp_dir("font-search-fuzz").join(format!("{:?}.ttc", std::thread::current().id()));
+ let dir = vectorcraft_testkit::TempDir::new("font-search-fuzz");
+ let path = dir.join("font.ttc");
std::fs::write(&path, bytes).unwrap();
catch_quiet(|| findme_wanted().provided_by(&path)).map_err(|e| TestCaseError::fail(format!("{what}: a search reading the file panicked: {e}")))
}
diff --git a/crates/engine/tests/it/model_based.rs b/crates/engine/tests/it/model_based.rs
index b11e3b0591..583dfda441 100644
--- a/crates/engine/tests/it/model_based.rs
+++ b/crates/engine/tests/it/model_based.rs
@@ -134,7 +134,8 @@ fn a_failed_batch_restores_the_file_the_history_and_a_drag_in_progress() {
(doc_json(&st.doc), st.history.undo.len(), st.history.redo.len())
};
// Save As, then a failing step: the document keeps no file and stays modified.
- let path = vectorcraft_testkit::temp_dir("batch-rollback").join("named.vectorcraft").to_string_lossy().to_string();
+ let dir = vectorcraft_testkit::TempDir::new("batch-rollback");
+ let path = dir.join("named.vectorcraft").to_string_lossy().to_string();
let save = json!({"command": "file.saveAs", "params": {"path": path}});
assert!(s.execute("command.batch", &json!({"commands": [rect(0), save, fail]})).is_err());
assert_eq!(state(&s), before);
@@ -170,7 +171,7 @@ fn empty_batch_is_harmless() {
#[test]
fn save_open_via_commands_roundtrips() {
let mut s = fixtures::rich_session();
- let dir = vectorcraft_testkit::temp_dir("engine-save");
+ let dir = vectorcraft_testkit::TempDir::new("engine-save");
let path = dir.join("rich.vectorcraft");
exec(&mut s, "document.save", json!({"path": path.to_str().unwrap()}));
let want = doc_json(&s.doc().unwrap().doc);
diff --git a/crates/engine/tests/removed_font.rs b/crates/engine/tests/removed_font.rs
index ca5dc1f13a..da885b40b8 100644
--- a/crates/engine/tests/removed_font.rs
+++ b/crates/engine/tests/removed_font.rs
@@ -37,9 +37,7 @@ fn laid_out_in(s: &Session, id: u64) -> Vec {
#[test]
fn type_in_a_removed_font_falls_back_after_a_rescan() {
- let root = vectorcraft_testkit::temp_dir("removed-font");
- let _ = std::fs::remove_dir_all(&root);
- std::fs::create_dir_all(&root).unwrap();
+ let root = vectorcraft_testkit::TempDir::new("removed-font");
vectorcraft_text::set_user_font_dirs(vec![root.clone()]);
let file = root.join("Gonefont.otf");
std::fs::write(&file, vectorcraft_testkit::fonts::renamed(FAMILY)).unwrap();
@@ -72,5 +70,4 @@ fn type_in_a_removed_font_falls_back_after_a_rescan() {
assert_eq!(status(&mut s), ("exact".into(), FAMILY.into()));
assert!(listed(&mut s));
assert_eq!(laid_out_in(&s, id), [FAMILY]);
- let _ = std::fs::remove_dir_all(&root);
}
diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs
index ab39f467d2..1321188f12 100644
--- a/crates/eps/src/ai_editing/tests.rs
+++ b/crates/eps/src/ai_editing/tests.rs
@@ -2634,6 +2634,34 @@ fn gradient_options_and_links_the_data_cant_hold_are_reported() {
let back = read_back(&doc);
let Paint::Gradient(g) = back.layers[0].children().unwrap()[0].appearance.items[0].paint() else { panic!("a gradient") };
assert_eq!((color(&g.gradient.stops[0].color), g.gradient.stops[0].swatch.as_deref()), (color(&BRAND), None));
+ // A gradient mixed around the hue wheel, either way, is written as a classic one of the stops
+ // on its curve, no more than the reader reads, and opens with the colors it had.
+ for method in [vectorcraft_color::gradient::GradientMethod::HueClockwise, vectorcraft_color::gradient::GradientMethod::HueCounterclockwise] {
+ let mut doc = Document::new(200.0, 100.0);
+ let layer = doc.layers[0].id;
+ let mut g = sunset();
+ g.method = method;
+ let want = g.clone();
+ let n = painted(
+ &mut doc,
+ shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)),
+ vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(g, None, None)))],
+ );
+ doc.insert(Some(layer), usize::MAX, n).unwrap();
+ let out = export_ai_editing(&doc, &opts()).unwrap();
+ assert_eq!(out.warnings, [writer::GRADIENT_HUE], "{method:?}");
+ let back = read_back(&doc);
+ let Paint::Gradient(g) = back.layers[0].children().unwrap()[0].appearance.items[0].paint() else { panic!("a gradient") };
+ assert!(g.gradient.method.is_classic(), "{method:?}");
+ let written = g.gradient.stops.len();
+ assert!((want.stops.len() + 1..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{method:?}: {written} stops");
+ for k in 0..=100 {
+ let t = k as f32 / 100.0;
+ let ((a, ao), (b, bo)) = (want.sample(t), g.gradient.sample(t));
+ let (a, b) = (a.to_rgb(), b.to_rgb());
+ assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.03) && (ao - bo).abs() < 0.03, "{method:?} at {t}: {a:?} {ao} vs {b:?} {bo}");
+ }
+ }
}
/// A pattern whose art reaches past its tile is written as its tiles cut to the shape: a definition
diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs
index cd0455f10f..7db5d11896 100644
--- a/crates/eps/src/ai_editing/writer.rs
+++ b/crates/eps/src/ai_editing/writer.rs
@@ -36,7 +36,7 @@ pub(super) use images::PAYLOAD;
#[cfg(test)]
pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED};
#[cfg(test)]
-pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name};
+pub(super) use paints::{GRADIENT_HUE, GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name};
#[cfg(test)]
pub(super) use symbols::{MISSING_SYMBOL, SYMBOL_ART, SYMBOL_COUNT, SYMBOL_OPTIONS};
#[cfg(test)]
diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs
index 0a7220faa5..f1b52dd91b 100644
--- a/crates/eps/src/ai_editing/writer/paints.rs
+++ b/crates/eps/src/ai_editing/writer/paints.rs
@@ -21,6 +21,7 @@ pub(in crate::ai_editing) const SWATCH_PLACEMENT: &str =
"the placement of pattern swatches that move, turn or scale their pattern is left out of the editing data for other apps";
pub(in crate::ai_editing) const GRADIENT_MIX: &str =
"gradients that mix their colors perceptually or dither are written as classic gradients in the editing data for other apps";
+pub(in crate::ai_editing) const GRADIENT_HUE: &str = "gradients mixed around the hue wheel are written in process colors, with the colors between their stops as stops of their own (at most 256), in the editing data for other apps";
pub(in crate::ai_editing) const CONIC: &str =
"conic gradients are written as linear gradients along their vector, with the same stops, in the editing data for other apps";
pub(in crate::ai_editing) const STOP_LINKS: &str =
@@ -56,6 +57,19 @@ fn writable_gradient(g: &Gradient) -> bool {
g.kind != GradientKind::Freeform && !g.stops.is_empty() && g.stops.len() <= crate::import::MAX_GRADIENT_STOPS
}
+/// `g` as the data holds its colors: one mixed around the hue wheel (the data's gradients mix
+/// their stops' colors directly) as a classic gradient of the stops on its curve
+/// ([`Gradient::as_classic_curve`]), no more than the `.ai` reader reads when it pads
+/// ([`GRADIENT_HUE`]); others as they are.
+fn hue_as_stops(g: &Gradient) -> std::borrow::Cow<'_, Gradient> {
+ use std::borrow::Cow;
+ if !g.method.is_hue() {
+ return Cow::Borrowed(g);
+ }
+ let curve = g.as_classic_curve();
+ Cow::Owned(if curve.is_spread() { curve } else { curve.fitted(crate::import::MAX_GRADIENT_STOPS).into_owned() })
+}
+
/// Is a gradient of `kind` written as a radial one: a radial gradient, or a diamond (the ellipse
/// through its square's corners, [`DIAMOND`])?
fn written_radial(kind: GradientKind) -> bool {
@@ -86,9 +100,11 @@ impl Writer<'_> {
pub(in crate::ai_editing) fn reserve_swatch_gradients(&mut self) {
let doc = self.doc;
for sw in doc.swatches_iter() {
- // As `swatch_paint` writes it: the stops of one that repeats or reflects repeated.
+ // As `swatch_paint` writes it: one mixed around the hue wheel as the stops on its curve,
+ // and the stops of one that repeats or reflects repeated.
if let Paint::Gradient(g) = &sw.paint
- && let gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS)
+ && let stops = hue_as_stops(&g.gradient)
+ && let gradient = stops.unrolled_within(crate::import::MAX_GRADIENT_STOPS)
&& writable_gradient(&gradient)
&& !self.gradients.iter().any(|d| d.name == sw.name)
{
@@ -149,6 +165,10 @@ impl Writer<'_> {
// The data has no spread: a gradient that repeats or reflects is written with its stops
// repeated over `area` (a conic one, written linear, around its turn).
let mut g: GradientPaint = (**g).clone();
+ if g.gradient.method.is_hue() {
+ self.warn(GRADIENT_HUE);
+ g.gradient = hue_as_stops(&g.gradient).into_owned();
+ }
if g.gradient.is_spread() {
self.warn(SPREAD);
match g.gradient.padded_within(&geom, area, crate::import::MAX_GRADIENT_STOPS) {
@@ -557,13 +577,17 @@ impl Writer<'_> {
/// be written.
pub(super) fn swatch_paint(&mut self, s: &mut String, sw: &vectorcraft_color::swatch::Swatch) -> bool {
// A swatch has no placement: one that repeats or reflects holds its stops repeated along
- // its vector.
- let unrolled = match &sw.paint {
- Paint::Gradient(g) => Some(g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS)),
+ // its vector, and one mixed around the hue wheel the stops on its curve.
+ let stops = match &sw.paint {
+ Paint::Gradient(g) => Some(hue_as_stops(&g.gradient)),
_ => None,
};
+ let unrolled = stops.as_deref().map(|g| g.unrolled_within(crate::import::MAX_GRADIENT_STOPS));
match &sw.paint {
Paint::Gradient(g) if unrolled.as_deref().is_some_and(writable_gradient) => {
+ if g.gradient.method.is_hue() {
+ self.warn(GRADIENT_HUE);
+ }
if g.gradient.is_spread() {
self.warn(SPREAD);
}
diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs
index a3de7560e3..4595061151 100644
--- a/crates/eps/src/import/ai.rs
+++ b/crates/eps/src/import/ai.rs
@@ -39,6 +39,7 @@ pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
pub(crate) use envelope::MAX_CHECKED;
pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN;
pub(crate) use envelope::{GridPoint as EnvelopeGridPoint, PLUGIN as DEFORM_PLUGIN, outline as envelope_outline, upright as envelope_upright};
+pub(crate) use envelope::{MAX_ANY as ENVELOPE_MAX_ANY, MAX_SOLID as ENVELOPE_MAX_SOLID};
pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType};
pub(crate) use frames::text_frames;
pub use obj::{NameEncoding, Names};
diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs
index 741ebfabdf..246121662d 100644
--- a/crates/eps/src/import/ai/envelope.rs
+++ b/crates/eps/src/import/ai/envelope.rs
@@ -73,9 +73,9 @@ const MOVED_SLACK: f64 = 0.5;
const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17;
/// More of the inked pixels than this differing, each with all its neighbours, and the envelope
/// doesn't come out as drawn.
-const MAX_SOLID: f64 = 0.03;
+pub(crate) const MAX_SOLID: f64 = 0.03;
/// More of the inked pixels than this differing at all, and the envelope doesn't come out as drawn.
-const MAX_ANY: f64 = 0.1;
+pub(crate) const MAX_ANY: f64 = 0.1;
/// The whole number of entry `key`: `None` when it is missing, `Some(None)` when it isn't one.
fn whole(o: &Obj, key: &str) -> Option> {
@@ -648,6 +648,9 @@ pub(crate) enum TypeKept {
Budget,
/// Its live envelope doesn't draw as its drawn art.
Differs,
+ /// A page that the layers, which don't draw as it, were compared with holds it against them
+ /// ([`WarpedType::open_alone`]).
+ Page,
}
/// What [`WarpedType::settle_envelopes`] did.
@@ -657,9 +660,10 @@ pub(crate) struct TypedSettled {
pub(crate) live: usize,
/// Why each of the others kept its drawn art.
pub(crate) kept: Vec,
- /// Each live envelope's id, with what takes its place again when the layers with it don't draw
- /// as the page: its hidden type, then its drawn art.
- pub(crate) put_back: Vec<(NodeId, Vec)>,
+ /// Each live envelope's place among the envelopes of type (in the order they were read) and
+ /// id, with what takes its place again when the layers with it don't draw as the page: its
+ /// hidden type, then its drawn art.
+ pub(crate) put_back: Vec<(usize, NodeId, Vec)>,
}
/// The text objects read with a Warp and the envelopes of type, and what the import has left of the
@@ -672,6 +676,9 @@ pub struct WarpedType {
pub(super) checks: usize,
pub(super) spent: Spent,
pub(super) page: Option,
+ /// The envelopes of type that may open live in layers built without a page, by their place
+ /// among them ([`Self::open_alone`]): none when `None`.
+ pub(super) alone: Option>,
}
impl WarpedType {
@@ -682,14 +689,33 @@ impl WarpedType {
/// These, for layers `compared` with a page. Layers that aren't leave the envelopes of type
/// their drawn art and their hidden type, with the note ([`TYPE_DRAWN`]) added to `notes`: a
- /// live envelope opens only where the page chooses it.
+ /// live envelope opens only where the page chooses it. Unless [`Self::open_alone`] names some:
+ /// then they all wait, as with a page, and only those named may stay live.
pub(crate) fn compared(mut self, compared: bool, notes: &mut Vec) -> Self {
- if !compared && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) {
+ if !compared && self.alone.is_none() && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) {
notes.push(TYPE_DRAWN.into());
}
self
}
+ /// Let the envelopes of type at `places` (among them, in the order they were read) open live in
+ /// layers built without a page, where they draw as their drawn art: a page these same layers,
+ /// read the same way, were compared with weighed them and didn't hold them against the layers'
+ /// difference from it. All the envelopes of type are settled as they were then, in the same
+ /// order and with the same checks, so each that opens live draws as it was weighed; then the
+ /// others get their drawn art back ([`Self::settle_envelopes`]). `count` is how many envelopes
+ /// of type that reading had: a reading with another number of them names none.
+ pub(crate) fn open_alone(&mut self, places: &[usize], count: usize) {
+ if self.envelopes.len() == count {
+ self.alone = Some(places.iter().copied().filter(|i| *i < count).collect());
+ }
+ }
+
+ /// How many envelopes of type wait for their type.
+ pub(crate) fn envelope_count(&self) -> usize {
+ self.envelopes.len()
+ }
+
/// Make each envelope of type live where it draws as its drawn art, its contents' text slots
/// holding their type: `type_of` gives, for a slot's id, the hidden text object it became in
/// `doc` and the type the contents take in its place (the slot's visibility and lock are the
@@ -713,16 +739,28 @@ impl WarpedType {
doc.layers.iter().for_each(|l| collect_shown(l, &mut shown));
let envelopes = std::mem::take(&mut self.envelopes);
let mut waiting = envelopes.len();
- for envelope in envelopes {
+ for (place, envelope) in envelopes.into_iter().enumerate() {
waiting -= 1;
match self.typed_live(doc, envelope, &type_of, &shown, waiting) {
- Ok(put_back) => {
+ Ok((id, put_back)) => {
settled.live += 1;
- settled.put_back.push(put_back);
+ settled.put_back.push((place, id, put_back));
}
Err(why) => settled.kept.push(why),
}
}
+ // Built without a page, the envelopes [`Self::open_alone`] doesn't name get their drawn art
+ // and hidden type back. One inside another that got its own back went with that art.
+ if let Some(free) = &self.alone {
+ let (stay, back): (Vec<_>, Vec<_>) = std::mem::take(&mut settled.put_back).into_iter().partition(|(place, ..)| free.contains(place));
+ for (_, id, nodes) in back {
+ if doc.insert_beside(vec![(id, nodes)], true).is_ok() || doc.node(id).is_none() {
+ settled.live = settled.live.saturating_sub(1);
+ settled.kept.push(TypeKept::Page);
+ }
+ }
+ settled.put_back = stay;
+ }
settled
}
diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs
index ca943e8817..0bcae74b32 100644
--- a/crates/eps/src/import/ai/read/document.rs
+++ b/crates/eps/src/import/ai/read/document.rs
@@ -1367,6 +1367,7 @@ impl<'a> Reader<'a> {
checks: self.envelope_checks,
spent: self.envelope_spent,
page,
+ alone: None,
},
styles_read,
text_patterns,
diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs
index 9b86f38350..f2a0a41ca7 100644
--- a/crates/eps/src/import/ai/tests.rs
+++ b/crates/eps/src/import/ai/tests.rs
@@ -8280,7 +8280,7 @@ fn warped_type_on_the_page_spends_what_is_left_of_the_budget() {
let copy = |w: &super::envelope::WarpedLook| super::envelope::WarpedLook { slot: w.slot, look: w.look.clone(), left_out: None };
let page = Some(Rect::new(0.0, 60.0, 60.0, 100.0));
let settle = |w: &super::envelope::WarpedLook, spent| {
- let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page };
+ let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page, alone: None };
warped.settle(&mut doc.clone(), Some, &Default::default(), &mut vec![])
};
let spent = || super::envelope::Spent { any: MAX_DIFFERENCE / 2.0, solid: MAX_SOLID_DIFFERENCE / 2.0 };
diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs
index 8adcfccef0..1b212194a1 100644
--- a/crates/eps/src/import/native/build.rs
+++ b/crates/eps/src/import/native/build.rs
@@ -1,5 +1,6 @@
use super::compare::{
- MAX_DIFFERENCE, MAX_PAGES_COMPARED, MAX_PIXELS_COMPARED, MAX_SOLID_DIFFERENCE, PageView, compare, comparison_samples, difference, pixels_compared,
+ MAX_DIFFERENCE, MAX_PAGES_COMPARED, MAX_PIXELS_COMPARED, MAX_SOLID_DIFFERENCE, PAST_THE_PAGE, PageView, compare, comparison_samples, difference,
+ pixels_compared,
};
use super::plan::{made_only, make_slots, plan, readable_all};
use super::slots::{
@@ -230,6 +231,24 @@ pub(super) fn build_from(
outlined: bool,
trust: bool,
unit: f64,
+) -> Result<(Document, Vec), String> {
+ build_weighing(structure, data, visible, page, outlined, trust, unit, &mut None)
+}
+
+/// [`build_from`]. When the layers with their live envelopes don't draw as the page, and don't with
+/// the drawn art of every live envelope put back either, `free` is set to the envelopes of type
+/// (by their place among them, as read) that opened live and that the page doesn't hold against
+/// them ([`held_back`]), whether or not the layers are then used, and however.
+#[allow(clippy::too_many_arguments)]
+pub(super) fn build_weighing(
+ structure: Structure,
+ data: &[u8],
+ visible: Option<&Document>,
+ page: Page,
+ outlined: bool,
+ trust: bool,
+ unit: f64,
+ free: &mut Option>,
) -> Result<(Document, Vec), String> {
let Structure {
mut doc,
@@ -468,12 +487,14 @@ pub(super) fn build_from(
let warped_type = warped_type.compared(frame.is_some(), &mut notes);
let bound = [&bound_text, &bound_visible];
let typed = settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, bound, &mut restyled, &mut notes)?;
+ // Each live envelope of type's place among the envelopes of type, by its id.
+ let places: Vec<(NodeId, usize)> = typed.iter().map(|(place, id, _)| (*id, *place)).collect();
// The live envelopes and what takes the place of each again, when the layers with them don't
// draw as the page, with the note that says so.
let envelopes_drawn: Vec<(NodeId, Vec, &str)> = envelopes_drawn
.into_iter()
.map(|(id, art)| (id, vec![art], ai::ENVELOPE_DRAWN))
- .chain(typed.into_iter().map(|(id, nodes)| (id, nodes, ai::ENVELOPE_TYPE_DRAWN)))
+ .chain(typed.into_iter().map(|(_, id, nodes)| (id, nodes, ai::ENVELOPE_TYPE_DRAWN)))
.collect();
if empty > 0 {
notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT }));
@@ -525,20 +546,21 @@ pub(super) fn build_from(
notes.extend(unseen_note(unseen).into_iter().chain(note));
}
other => match drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, ¬es, unseen, trust, why, reflow) {
- Ok(more) => notes.extend(more),
+ Ok((more, held)) => {
+ *free = held.map(|held| free_places(&places, &held));
+ notes.extend(more);
+ }
// The objects that draw differently from the page come in as its art, from
// the placement that draws closer to it. The layers don't draw as the page:
- // envelopes of type keep their drawn art and their type, hidden.
+ // the envelopes of type it holds against them keep their drawn art and their
+ // type, hidden. Where no placement's differing objects can then come in as the
+ // page's art (an object that holds type isn't replaced), every envelope of type
+ // keeps its drawn art, and that is tried again.
Err(why) => {
- let typed = type_drawn(&mut doc, &envelopes_drawn);
- let other = other.map(|(_, mut other, unseen)| {
- type_drawn(&mut other, &envelopes_drawn);
- (other, unseen)
- });
- let placements = closer_first(&frame, &mut view, (doc, unseen), other, reflow);
- let Some((kept, unseen, kept_notes)) = placements.into_iter().find_map(|(base, unseen)| {
- keep_differing(&base, &frame, &mut view, &threaded_frames, &shown, reflow).map(|(kept, n)| (kept, unseen, n))
- }) else {
+ let other = other.map(|(_, other, unseen)| (other, unseen));
+ let layers = Placements { doc: &doc, unseen, other: other.as_ref(), drawn: &envelopes_drawn, places: &places };
+ let found = keep_differing_typed(&frame, &mut view, &layers, &threaded_frames, &shown, reflow, free);
+ let Some((kept, unseen, kept_notes, typed)) = found else {
return Err(why);
};
doc = kept;
@@ -693,8 +715,9 @@ fn bound_slots(
/// anchors. Before that, the envelopes of type open live where they draw as their drawn art, with
/// the type of their text slots (made type, one text object for the slot, not threaded, bound to
/// no text variable and no visibility variable, `bound`'s second set, and no wrap object); the
-/// others keep their drawn art, with a note. Returns each live envelope of type's id with what
-/// takes its place again (its hidden type, then its drawn art).
+/// others keep their drawn art, with a note. Returns each live envelope of type's place among the
+/// envelopes of type (in the order they were read) and id, with what takes its place again (its
+/// hidden type, then its drawn art).
fn settle_warped_type(
mut warped: ai::WarpedType,
doc: &mut Document,
@@ -703,7 +726,7 @@ fn settle_warped_type(
bound: [&BTreeSet; 2],
restyled: &mut Vec,
notes: &mut Vec,
-) -> Result)>, String> {
+) -> Result)>, String> {
let [bound_text, bound_visible] = bound;
let made = |slot| match filled.get(&slot).map(|f| f.text.as_slice()).filter(|_| !bound_text.contains(&slot)) {
Some(&[one]) => Some(one),
@@ -1152,15 +1175,15 @@ const MAX_DIFFERING: usize = 64;
const MAX_PATCH_PAIRS: usize = 4_000_000;
/// The layers as read and at the placed files' other placement (each with its count of text
-/// objects the page doesn't draw), the one that draws closer to the page first. `reflow` as in
-/// [`PageView::difference`].
-fn closer_first(
+/// objects the page doesn't draw, and what goes with it), the one that draws closer to the page
+/// first. `reflow` as in [`PageView::difference`].
+fn closer_first(
frame: &Frame<'_>,
view: &mut PageView,
- doc: (Document, usize),
- other: Option<(Document, usize)>,
+ doc: (Document, usize, T),
+ other: Option<(Document, usize, T)>,
reflow: bool,
-) -> Vec<(Document, usize)> {
+) -> Vec<(Document, usize, T)> {
match other {
Some(other)
if view.difference_on_every_page(&other.0, frame.rect, reflow).any < view.difference_on_every_page(&doc.0, frame.rect, reflow).any =>
@@ -1278,6 +1301,73 @@ fn keep_differing(
Some((kept, note.into_iter().chain([format!("{DREW_DIFFERENTLY}: {}", layers.join(", "))]).collect()))
}
+/// The places (among the envelopes of type, as read) of the live envelopes of type in `places`
+/// (each with its id) that `held` doesn't list.
+fn free_places(places: &[(NodeId, usize)], held: &BTreeSet) -> Vec {
+ places.iter().filter(|(id, _)| !held.contains(id)).map(|(_, place)| *place).collect()
+}
+
+/// The layers of [`keep_differing_typed`]: `doc`, `unseen` text objects of it taken out; the placed
+/// files' other placement of it, when it has one, with how many of its text objects were; its live
+/// envelopes, as [`drawn_or_trusted`] takes them; and the place of each live envelope of type
+/// among the envelopes of type, by its id.
+struct Placements<'a> {
+ doc: &'a Document,
+ unseen: usize,
+ other: Option<&'a (Document, usize)>,
+ drawn: &'a [(NodeId, Vec, &'a str)],
+ places: &'a [(NodeId, usize)],
+}
+
+/// [`keep_differing`] of the placement of `layers` that draws closer to the page, the envelopes of
+/// type the page holds against that placement ([`held_back`], each placement weighed on its own: the
+/// other one draws placed files elsewhere, under or over the envelopes) having their drawn art and
+/// hidden type back; and where none of the placements' differing objects can then come in as the
+/// page's art, every envelope of type: the document, how many of its text objects were taken out,
+/// its notes, and whether an envelope of type got its drawn art back. A placement that draws as the
+/// page with only the envelopes the page holds put back is used as it is, as one with all the live
+/// envelopes' drawn art put back would have been ([`drawn_or_trusted`]): nothing in it differs to
+/// come in as the page's art. `free` is set to the places of those the page doesn't hold against
+/// the layers as read.
+#[allow(clippy::too_many_arguments)]
+fn keep_differing_typed(
+ frame: &Frame<'_>,
+ view: &mut PageView,
+ layers: &Placements<'_>,
+ threaded_frames: &BTreeSet,
+ shown: &[Vec>],
+ reflow: bool,
+ free: &mut Option>,
+) -> Option<(Document, usize, Vec, bool)> {
+ let held = held_back(frame, view, layers.doc, layers.drawn, reflow);
+ *free = Some(free_places(layers.places, &held));
+ let held_other = layers.other.map(|(other, _)| held_back(frame, view, other, layers.drawn, reflow));
+ let all: BTreeSet = layers.places.iter().map(|(id, _)| *id).collect();
+ let fewer = held.len() < all.len() || held_other.as_ref().is_some_and(|h| h.len() < all.len());
+ let mut tries = vec![];
+ if fewer {
+ tries.push((held, held_other));
+ }
+ tries.push((all.clone(), Some(all.clone())));
+ tries.into_iter().find_map(|(held, held_other)| {
+ // Each placement with what it put back: whether an envelope of type, and whether only some.
+ let mut base = layers.doc.clone();
+ let typed = (type_drawn(&mut base, layers.drawn, &held), held.len() < all.len());
+ let other = layers.other.map(|(other, unseen)| {
+ let (mut other, held) = (other.clone(), held_other.as_ref().unwrap_or(&held));
+ let typed = (type_drawn(&mut other, layers.drawn, held), held.len() < all.len());
+ (other, *unseen, typed)
+ });
+ let placements = closer_first(frame, view, (base, layers.unseen, typed), other, reflow);
+ placements.into_iter().find_map(|(base, unseen, (typed, some))| {
+ if some && let Ok(notes) = compare(frame, view, &base, reflow) {
+ return Some((base, unseen, notes, typed));
+ }
+ keep_differing(&base, frame, view, threaded_frames, shown, reflow).map(|(kept, notes)| (kept, unseen, notes, typed))
+ })
+ })
+}
+
/// Copy page drawing IDs, including mask art outside the ordinary child tree.
pub(super) fn reid_page_art(doc: &mut Document, source: &Node) -> Node {
let mut copy = doc.reid(source);
@@ -1338,9 +1428,9 @@ fn slot_content(
/// The layers `doc` don't draw as the page (`why`), `unseen` text objects of them taken out: the
/// notes to add when `doc` with each live envelope `drawn` lists replaced by the drawn art it took
/// the place of (see [`ai::Structure::envelopes_drawn`]) draws as the page, `doc` then being that;
-/// else, with `trust`, the notes for `doc` as it is but for its envelopes of type, which keep their
-/// drawn art ([`type_drawn`]). An error, `why`, otherwise. The envelope note is added unless
-/// `notes` has it.
+/// else, with `trust`, the notes for `doc` as it is but for the envelopes of type the page holds
+/// against it ([`held_back`]), which keep their drawn art ([`type_drawn`]), with those envelopes.
+/// An error, `why`, otherwise. The envelope note is added unless `notes` has it.
#[allow(clippy::too_many_arguments)]
fn drawn_or_trusted(
frame: &Frame<'_>,
@@ -1352,7 +1442,7 @@ fn drawn_or_trusted(
trust: bool,
why: String,
reflow: bool,
-) -> Result, String> {
+) -> Result<(Vec, Option>), String> {
let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}"));
let mut put_back = doc.clone();
// Each envelope still there, in place of the art it took the place of.
@@ -1379,26 +1469,143 @@ fn drawn_or_trusted(
envelope.push(say.to_string());
}
}
- return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect());
+ return Ok((envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect(), None));
}
}
if trust {
- let typed = type_drawn(doc, drawn).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n));
+ let held = held_back(frame, view, doc, drawn, reflow);
+ let typed = type_drawn(doc, drawn, &held).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n));
let trusted = format!("{why}: they come from its editing data all the same");
- return Ok(typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect());
+ return Ok((typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect(), Some(held)));
}
Err(why)
}
+/// The live envelopes of type in `drawn` (as [`drawn_or_trusted`] takes them) that the page holds
+/// against the layers `doc`, which don't draw as it even with every live envelope's drawn art put
+/// back: by their ids, those where the layers don't draw as the page. The envelope and its drawn art
+/// are drawn together on a layer of their own, without the rest of the layers, on the page check's
+/// drawing of each page they lie on: in the pixels they ink there, the layers with the envelope may
+/// differ from the page in no more than the envelope's own check allows against its drawn art (at
+/// all, and with all their neighbors, away from type: [`compare`]); and in the order they were
+/// read, the envelopes that stay live may differ there in no more of each page together than half
+/// of what the page check allows (a budget of the same size as the one their own checks share, kept
+/// apart from it). Art that differs elsewhere on the page doesn't hold them. One the layers don't
+/// draw where it is and as itself ([`drawn_in_place`]) is held, wherever it lies: drawn on its own,
+/// it would be weighed elsewhere than it draws, or on pixels a clip or mask hides, and what holds it
+/// can draw it onto a page from off it. Another envelope on no page stays live, as the page check
+/// doesn't see it, unless the page holds all the art that prints ([`Frame::whole`]), where art past
+/// it is compared too: there one reaching past the page is held. Past [`MAX_WEIGHED`] of them on
+/// the pages, or where the page check can't weigh one, they are all held.
+fn held_back(frame: &Frame<'_>, view: &mut PageView, doc: &Document, drawn: &[(NodeId, Vec, &str)], reflow: bool) -> BTreeSet {
+ let typed: Vec<(NodeId, &Node, &Node)> = drawn
+ .iter()
+ .filter(|(_, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN)
+ .filter_map(|(id, art, _)| Some((*id, doc.node(*id)?, art.last()?)))
+ .collect();
+ let all: BTreeSet = typed.iter().map(|(id, ..)| *id).collect();
+ if all.is_empty() {
+ return all;
+ }
+ let pages = view.regions(frame.rect);
+ let past = |b: Rect| frame.whole && !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(b);
+ let mut held = BTreeSet::new();
+ let mut in_place = std::collections::HashMap::new();
+ drawn_in_place(&doc.layers, &all, true, &mut in_place);
+ // Those the page check sees, by the boxes of the envelope and its drawn art.
+ let mut seen = vec![];
+ for (id, live, art) in typed {
+ if !in_place.get(&id).copied().unwrap_or(false) {
+ held.insert(id);
+ continue;
+ }
+ match vectorcraft_geom::union_opt(live.visual_bounds(), art.visual_bounds()) {
+ Some(b) if past(b) => {
+ held.insert(id);
+ }
+ Some(b) if pages_touched(b, &pages, 1.0).is_empty() => {}
+ _ => seen.push((id, live, art)),
+ }
+ }
+ if seen.len() > MAX_WEIGHED {
+ return all;
+ }
+ // The pixels of each page where the envelopes that stay live differ from it: at all, and with
+ // all their neighbors.
+ let mut spent = vec![(0usize, 0usize); pages.len()];
+ for (id, live, art) in seen {
+ // The envelope and its drawn art, shown, on a layer of their own in `doc`.
+ let mut nodes: Vec> = [live, art].into_iter().cloned().map(Arc::new).collect();
+ for n in &mut nodes {
+ let n = Arc::make_mut(n);
+ (n.visible, n.opacity) = (true, 1.0);
+ }
+ let mut one = doc.clone();
+ let mut layer = Node::layer(NodeId(0), "", vectorcraft_doc::LayerColor::Preset(0));
+ if let Some(children) = layer.children_mut() {
+ *children = nodes;
+ }
+ one.layers = vec![Arc::new(layer)];
+ let bounds = vectorcraft_render::encode::art_bounds(&one);
+ if bounds.is_some_and(past) {
+ held.insert(id);
+ continue;
+ }
+ let on = bounds.map_or(vec![], |b| pages_touched(b, &pages, 1.0));
+ let mut added = spent.clone();
+ let mut fits = true;
+ for (i, ink) in view.inked(&one, &on) {
+ let Some((any, solid)) = view.differing_in(doc, i, &ink, reflow) else { return all };
+ let inked = ink.iter().filter(|p| **p).count() as f64;
+ let pixels = ink.len().max(1) as f64;
+ let Some((a, s)) = added.get_mut(i) else { return all };
+ *a += any;
+ *s += solid;
+ fits &= any as f64 <= inked * ai::ENVELOPE_MAX_ANY
+ && solid as f64 <= inked * ai::ENVELOPE_MAX_SOLID
+ && *a as f64 <= pixels * MAX_DIFFERENCE / 2.0
+ && *s as f64 <= pixels * MAX_SOLID_DIFFERENCE / 2.0;
+ }
+ if fits {
+ spent = added;
+ } else {
+ held.insert(id);
+ }
+ }
+ held
+}
+
+/// Which of the objects `ids` among `nodes` (or what they hold) are drawn where they are and as
+/// themselves (`plain`: so far, above `nodes`), into `out` by id: under layers and groups that
+/// neither clip them, mask them nor have an appearance of their own (whose effects can move or copy
+/// them), and in no envelope, repeat, blend or other object that draws what it holds elsewhere or
+/// more than once.
+fn drawn_in_place(nodes: &[Arc], ids: &BTreeSet, plain: bool, out: &mut std::collections::HashMap) {
+ for n in nodes {
+ if ids.contains(&n.id) {
+ out.insert(n.id, plain);
+ }
+ let Some(children) = n.children() else { continue };
+ let keeps = match n.kind {
+ NodeKind::Layer { clip, .. } | NodeKind::Group { clip, .. } => {
+ !clip && n.appearance == vectorcraft_doc::Appearance::default() && n.extras().mask.is_none()
+ }
+ _ => false,
+ };
+ drawn_in_place(children, ids, plain && keeps, out);
+ }
+}
+
/// Put each live envelope of type in `drawn` (as [`drawn_or_trusted`] takes them) that `doc` still
-/// has back as its hidden type and its drawn art: whether one was. An envelope of type opens live
-/// only where the layers with it draw as the page, so where they don't, though they are used all
-/// the same (the editing data trusted, or the objects that differ come in as the page's art), it
-/// keeps its drawn art.
-fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec, &str)]) -> bool {
+/// has and `held` lists back as its hidden type and its drawn art: whether one was. An envelope of
+/// type opens live only where the page doesn't hold it against the layers ([`held_back`]), so where
+/// they don't draw as the page though they are used all the same (the editing data trusted, or the
+/// objects that differ come in as the page's art), one the page holds against them keeps its drawn
+/// art.
+fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec, &str)], held: &BTreeSet) -> bool {
let typed: Vec<(NodeId, Vec)> = drawn
.iter()
- .filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && doc.node(*id).is_some())
+ .filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && held.contains(id) && doc.node(*id).is_some())
.map(|(id, art, _)| (*id, art.clone()))
.collect();
if typed.is_empty() || doc.insert_beside(typed, true).is_err() {
@@ -2562,4 +2769,359 @@ mod candidate_tests {
replace_unread(&mut doc, &frame, &unread, &[], &BTreeSet::new(), &mut copied).unwrap();
assert!(copied.contains(&nodes[0].id) && !copied.contains(&nodes[1].id));
}
+
+ /// A rectangle on `rect` filled with `color`, with an id of `doc`'s.
+ fn filled_rect(doc: &mut Document, rect: Rect, color: vectorcraft_color::Color) -> Node {
+ let paint = vectorcraft_color::Paint::solid(color);
+ Node::path(
+ doc.alloc_id(),
+ vectorcraft_geom::shapes::rectangle(rect),
+ vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0),
+ )
+ }
+
+ /// A live envelope as [`drawn_or_trusted`] takes it: its id, what takes its place again, its note.
+ type Envelope = (NodeId, Vec, &'static str);
+
+ /// The layers `doc` with a black rectangle on each of `lives` standing in for a live envelope of
+ /// type, its drawn art a black rectangle on the matching one of `arts`: the layers, and the
+ /// envelopes as [`drawn_or_trusted`] takes them.
+ fn stand_ins(lives: &[Rect], arts: &[Rect]) -> (Document, Vec) {
+ let mut doc = Document::new(100.0, 100.0);
+ let layer = doc.layers[0].id;
+ let mut drawn = vec![];
+ for (live, art) in lives.iter().zip(arts) {
+ let n = filled_rect(&mut doc, *live, vectorcraft_color::Color::BLACK);
+ let id = n.id;
+ doc.insert(Some(layer), 0, n).unwrap();
+ let art = filled_rect(&mut doc, *art, vectorcraft_color::Color::BLACK);
+ drawn.push((id, vec![art], ai::ENVELOPE_TYPE_DRAWN));
+ }
+ (doc, drawn)
+ }
+
+ /// The page holds an envelope of type against layers that don't draw as it only where the
+ /// layers with the envelope differ from the page where it or its drawn art inks: not where the
+ /// page draws other art that the layers don't have.
+ #[test]
+ fn the_page_holds_back_only_envelopes_of_type_where_it_differs_from_them() {
+ let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
+ let mut page = Document::new(100.0, 100.0);
+ let layer = page.layers[0].id;
+ // The first two envelopes' drawn art, and a square the layers don't have.
+ for r in [Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0), Rect::new(10.0, 60.0, 30.0, 80.0)] {
+ let n = filled_rect(&mut page, r, vectorcraft_color::Color::BLACK);
+ page.insert(Some(layer), 0, n).unwrap();
+ }
+ // Live where its drawn art is; moved off its drawn art; and where the page draws nothing.
+ let (doc, drawn) = stand_ins(
+ &[Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(66.0, 10.0, 86.0, 30.0), Rect::new(60.0, 60.0, 80.0, 80.0)],
+ &[Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0), Rect::new(60.0, 60.0, 80.0, 80.0)],
+ );
+ let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] };
+ let mut view = PageView::new(&frame);
+ assert!(compare(&frame, &mut view, &doc, false).is_err());
+ assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[1].0, drawn[2].0]));
+ // Only envelopes of type are weighed.
+ let other: Vec<_> = drawn.iter().map(|(id, art, _)| (*id, art.clone(), ai::ENVELOPE_DRAWN)).collect();
+ assert!(held_back(&frame, &mut view, &doc, &other, false).is_empty());
+ }
+
+ /// The envelopes of type that stay live may together differ from the page where they ink in no
+ /// more than half of what the page check allows: the next one past that is held back, though
+ /// on its own it would stay.
+ #[test]
+ fn envelopes_of_type_stay_live_within_half_of_what_the_page_check_allows() {
+ let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
+ let halves = [Rect::new(0.0, 0.0, 100.0, 25.0), Rect::new(0.0, 25.0, 100.0, 50.0)];
+ let mut page = Document::new(100.0, 100.0);
+ let layer = page.layers[0].id;
+ for r in halves {
+ let n = filled_rect(&mut page, r, vectorcraft_color::Color::BLACK);
+ page.insert(Some(layer), 0, n).unwrap();
+ }
+ // White lines two pixels high over each, 6 points apart: 8% of what each inks differs, 2% of
+ // the page, and none of it with all its neighbors.
+ for (i, y) in [3.0, 9.0, 15.0, 21.0, 28.0, 34.0, 40.0, 46.0].into_iter().enumerate() {
+ let n = filled_rect(&mut page, Rect::new(0.0, y, 100.0, y + 0.5), vectorcraft_color::Color::WHITE);
+ page.insert(Some(layer), halves.len() + i, n).unwrap();
+ }
+ let (doc, drawn) = stand_ins(&halves, &halves);
+ let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)], more: vec![] };
+ let mut view = PageView::new(&frame);
+ assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[1].0]));
+ assert!(held_back(&frame, &mut view, &doc, &drawn[1..], false).is_empty());
+ }
+
+ /// A page of 100 points with a black rectangle on each of `rects`, and its frame (`whole`: the
+ /// page holds all the art that prints).
+ fn black_page(rects: &[Rect]) -> Document {
+ let mut page = Document::new(100.0, 100.0);
+ let layer = page.layers[0].id;
+ for r in rects {
+ let n = filled_rect(&mut page, *r, vectorcraft_color::Color::BLACK);
+ page.insert(Some(layer), 0, n).unwrap();
+ }
+ page
+ }
+
+ /// The frame of `page`, its 100-point square (`whole`: the page holds all the art that prints).
+ fn frame_of(page: &Document, whole: bool) -> Frame<'_> {
+ let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
+ Frame { page, page_rect: rect, rect, whole, pages: vec![(rect, Affine::IDENTITY)], more: vec![] }
+ }
+
+ /// An envelope of type is held back where the layers with it differ from the page in more of
+ /// the pixels it and its drawn art ink than its own check allows against its drawn art, though
+ /// that is far less of the page than the page check allows.
+ #[test]
+ fn an_envelope_of_type_differing_in_more_of_its_pixels_than_its_check_allows_is_held_back() {
+ // Half a point off its drawn art, which the page draws: 22% of the pixels they ink differ,
+ // a tenth of a percent of the page, and none with all their neighbors.
+ let page = black_page(&[Rect::new(30.5, 10.0, 34.5, 20.0)]);
+ let (doc, drawn) = stand_ins(&[Rect::new(30.0, 10.0, 34.0, 20.0)], &[Rect::new(30.5, 10.0, 34.5, 20.0)]);
+ let frame = frame_of(&page, false);
+ let mut view = PageView::new(&frame);
+ assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), BTreeSet::from([drawn[0].0]));
+ }
+
+ /// An envelope of type that the layers don't draw where it is and as itself is held back though
+ /// it draws as the page, wherever it lies: one that a clip group or a clipping sublayer clips, an
+ /// opacity mask masks, or that a group with an appearance of its own, another envelope, a repeat
+ /// or a blend holds. Drawn on its own, it would be weighed on pixels a clip hides, or elsewhere
+ /// than it draws, and what holds it can draw it onto the page from off it. One in a plain group
+ /// is weighed, and one off the page in a plain group stays live.
+ #[test]
+ fn envelopes_of_type_the_layers_dont_draw_in_place_are_held_back() {
+ let at = |i: usize| {
+ let (x, y) = if i < 4 { (5.0 + 20.0 * i as f64, 5.0) } else { (5.0 + 20.0 * (i - 4) as f64, 30.0) };
+ Rect::new(x, y, x + 15.0, y + 15.0)
+ };
+ let off = [Rect::new(120.0, 5.0, 135.0, 20.0), Rect::new(140.0, 5.0, 155.0, 20.0)];
+ let page = black_page(&(0..8).map(at).collect::>());
+ let mut doc = Document::new(100.0, 100.0);
+ let layer = doc.layers[0].id;
+ let mut drawn = vec![];
+ let mut stand_in = |doc: &mut Document, r: Rect| {
+ let n = filled_rect(doc, r, vectorcraft_color::Color::BLACK);
+ drawn.push((n.id, vec![filled_rect(doc, r, vectorcraft_color::Color::BLACK)], ai::ENVELOPE_TYPE_DRAWN));
+ Arc::new(n)
+ };
+ let group = |doc: &mut Document, children: Vec>, clip: bool| Node::new(doc.alloc_id(), NodeKind::Group { children, clip });
+ let fill = vectorcraft_doc::Appearance {
+ items: vec![vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(
+ vectorcraft_color::Color::BLACK,
+ )))],
+ ..Default::default()
+ };
+ let mut objects = vec![];
+ // 0: in a plain group.
+ let n = stand_in(&mut doc, at(0));
+ objects.push(group(&mut doc, vec![n], false));
+ // 1: in a clip group.
+ let n = stand_in(&mut doc, at(1));
+ let mut clip = filled_rect(&mut doc, at(1).inflate(2.0, 2.0), vectorcraft_color::Color::BLACK);
+ if let NodeKind::Path { clipping, .. } = &mut clip.kind {
+ *clipping = true;
+ }
+ objects.push(group(&mut doc, vec![Arc::new(clip), n], true));
+ // 2: in another envelope.
+ let n = stand_in(&mut doc, at(2));
+ let r = at(2);
+ let points = vec![kurbo::Point::new(r.x0, r.y0), kurbo::Point::new(r.x1, r.y0), kurbo::Point::new(r.x0, r.y1), kurbo::Point::new(r.x1, r.y1)];
+ let kind = Box::new(vectorcraft_doc::live::EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] });
+ let options = vectorcraft_doc::live::EnvelopeOptions::NEW;
+ objects.push(Node::new(
+ doc.alloc_id(),
+ NodeKind::Envelope { content: vec![n], kind, fidelity: 50.0, editing: false, options, frame: Box::default() },
+ ));
+ // 3: in a group with an appearance of its own.
+ let n = stand_in(&mut doc, at(3));
+ let mut styled = group(&mut doc, vec![n], false);
+ styled.appearance = fill.clone().into();
+ objects.push(styled);
+ // 4: in a group with an opacity mask.
+ let n = stand_in(&mut doc, at(4));
+ let mut masked = group(&mut doc, vec![n], false);
+ let mask = filled_rect(&mut doc, at(4).inflate(2.0, 2.0), vectorcraft_color::Color::WHITE);
+ masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask, true)));
+ objects.push(masked);
+ // 5: in a clipping sublayer.
+ let n = stand_in(&mut doc, at(5));
+ let mut clip = filled_rect(&mut doc, at(5).inflate(2.0, 2.0), vectorcraft_color::Color::BLACK);
+ if let NodeKind::Path { clipping, .. } = &mut clip.kind {
+ *clipping = true;
+ }
+ let mut sublayer = Node::layer(doc.alloc_id(), "Clipped", vectorcraft_doc::LayerColor::Preset(1));
+ if let NodeKind::Layer { clip: c, children, .. } = &mut sublayer.kind {
+ (*c, *children) = (true, vec![Arc::new(clip), n]);
+ }
+ objects.push(sublayer);
+ // 6: in a repeat; 7: in a blend.
+ let n = stand_in(&mut doc, at(6));
+ objects.push(Node::new(doc.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec::grid(vec![n], 0.0, 0.0))));
+ let n = stand_in(&mut doc, at(7));
+ objects.push(Node::new(doc.alloc_id(), NodeKind::Blend { children: vec![n], spec: Box::default() }));
+ // 8: off the page in a group with an appearance of its own (an effect could draw it onto the
+ // page); 9: off the page in a plain group.
+ let n = stand_in(&mut doc, off[0]);
+ let mut styled = group(&mut doc, vec![n], false);
+ styled.appearance = fill.into();
+ objects.push(styled);
+ let n = stand_in(&mut doc, off[1]);
+ objects.push(group(&mut doc, vec![n], false));
+ for n in objects {
+ doc.insert(Some(layer), 0, n).unwrap();
+ }
+ let frame = frame_of(&page, false);
+ let mut view = PageView::new(&frame);
+ let held = held_back(&frame, &mut view, &doc, &drawn, false);
+ assert_eq!(held, drawn[1..9].iter().map(|(id, ..)| *id).collect::>());
+ }
+
+ /// On a page that holds all the art that prints, which the page check also compares past the
+ /// page, an envelope of type reaching past the page, or lying off it, is held back; on another
+ /// page, the part on the page is weighed, and one off it stays live.
+ #[test]
+ fn on_a_page_that_holds_all_the_art_an_envelope_of_type_past_it_is_held_back() {
+ let on = Rect::new(10.0, 10.0, 30.0, 30.0);
+ let across = Rect::new(90.0, 40.0, 110.0, 60.0);
+ let off = Rect::new(120.0, 10.0, 140.0, 30.0);
+ let page = black_page(&[on, Rect::new(90.0, 40.0, 100.0, 60.0)]);
+ let (doc, drawn) = stand_ins(&[on, across, off], &[on, across, off]);
+ for (whole, held) in [(true, BTreeSet::from([drawn[1].0, drawn[2].0])), (false, BTreeSet::new())] {
+ let frame = frame_of(&page, whole);
+ let mut view = PageView::new(&frame);
+ assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), held, "whole: {whole}");
+ }
+ // On the page by its box, past it by what it draws: a Transform effect of its own draws a
+ // copy of it 100 points to the right, off the page.
+ let page = black_page(&[on]);
+ let (mut doc, drawn) = stand_ins(&[on], &[on]);
+ let effect =
+ vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 100, "copies": 1}), visible: true };
+ doc.node_mut(drawn[0].0).unwrap().appearance.effects.push(effect);
+ for (whole, held) in [(true, BTreeSet::from([drawn[0].0])), (false, BTreeSet::new())] {
+ let frame = frame_of(&page, whole);
+ let mut view = PageView::new(&frame);
+ assert_eq!(held_back(&frame, &mut view, &doc, &drawn, false), held, "drawn past the page, whole: {whole}");
+ }
+ }
+
+ /// Where the objects that draw differently from the page are to come in as its art and the
+ /// envelopes of type the page doesn't hold stay live, but one of those holds the difference in
+ /// its box (an object that holds type isn't replaced), every envelope of type gets its drawn art
+ /// back and that is tried again: the layers are still used.
+ #[test]
+ fn objects_come_in_as_the_page_art_with_every_envelope_of_type_put_back_where_a_live_one_holds_a_difference() {
+ let (a, b, dot, other) = (
+ Rect::new(10.0, 10.0, 20.0, 20.0),
+ Rect::new(40.0, 10.0, 50.0, 20.0),
+ Rect::new(27.0, 12.0, 33.0, 18.0),
+ Rect::new(70.0, 70.0, 90.0, 90.0),
+ );
+ // The page draws a dot in the gap between the two rectangles of the envelope's stand-in.
+ let page = black_page(&[a, b, dot, other]);
+ let mut doc = Document::new(100.0, 100.0);
+ let layer = doc.layers[0].id;
+ let square = filled_rect(&mut doc, other, vectorcraft_color::Color::BLACK);
+ doc.insert(Some(layer), 0, square).unwrap();
+ // The stand-in holds type, hidden; its drawn art is the two rectangles.
+ let style = vectorcraft_doc::CharStyle::default();
+ let mut text =
+ Node::new(doc.alloc_id(), NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(kurbo::Point::new(12.0, 18.0), "A", style))));
+ text.visible = false;
+ let parts = vec![
+ Arc::new(filled_rect(&mut doc, a, vectorcraft_color::Color::BLACK)),
+ Arc::new(filled_rect(&mut doc, b, vectorcraft_color::Color::BLACK)),
+ ];
+ let mut children = parts.clone();
+ children.push(Arc::new(text));
+ let live = Node::new(doc.alloc_id(), NodeKind::Group { children, clip: false });
+ let id = live.id;
+ doc.insert(Some(layer), 0, live).unwrap();
+ let art = Node::new(doc.alloc_id(), NodeKind::Group { children: parts, clip: false });
+ let drawn: Vec = vec![(id, vec![art], ai::ENVELOPE_TYPE_DRAWN)];
+ let frame = frame_of(&page, false);
+ let mut view = PageView::new(&frame);
+ assert!(compare(&frame, &mut view, &doc, false).is_err());
+ let layers = Placements { doc: &doc, unseen: 0, other: None, drawn: &drawn, places: &[(id, 0)] };
+ let mut free = None;
+ let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free);
+ assert_eq!(free, Some(vec![0]), "the page doesn't hold the envelope");
+ let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") };
+ assert!(typed, "the envelope got its drawn art back");
+ assert!(kept.node(id).is_none());
+ assert!(notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}");
+ }
+
+ /// The placed files' other placement is weighed on its own: an envelope of type that the layers
+ /// as read draw as the page, but that a placed file moved over it at the other placement makes
+ /// differ, gets its drawn art back there, where that placement's differing objects then come in
+ /// as the page's art.
+ #[test]
+ fn the_other_placement_of_placed_files_is_weighed_on_its_own() {
+ let black = vectorcraft_color::Color::BLACK;
+ let gray = vectorcraft_color::Color::rgb(0.5, 0.5, 0.5);
+ let (envelope, fillers) = (Rect::new(10.0, 10.0, 30.0, 30.0), [Rect::new(40.0, 80.0, 50.0, 90.0), Rect::new(70.0, 80.0, 80.0, 90.0)]);
+ let placed = Rect::new(60.0, 60.0, 76.0, 76.0);
+ // The page draws the envelope's drawn art in two halves.
+ let page = black_page(&[Rect::new(10.0, 10.0, 20.0, 30.0), Rect::new(20.0, 10.0, 30.0, 30.0), placed, fillers[0], fillers[1]]);
+ let (mut doc, drawn) = stand_ins(&[envelope], &[envelope]);
+ let layer = doc.layers[0].id;
+ for r in fillers {
+ let n = filled_rect(&mut doc, r, black);
+ doc.insert(Some(layer), 0, n).unwrap();
+ }
+ // Two placed files, standing in as rectangles: one 20 points above where the page has it, one
+ // a gray rectangle off the art. At their other placement the first is where the page has it
+ // and the gray one lies over half of the envelope.
+ let image = filled_rect(&mut doc, Rect::new(60.0, 40.0, 76.0, 56.0), black);
+ let strip = filled_rect(&mut doc, Rect::new(85.0, 40.0, 95.0, 60.0), gray);
+ // On top of the layer: the strip covers the envelope where it lies over it.
+ let top = doc.layers[0].children().map_or(0, Vec::len);
+ let (image, strip) = (doc.insert(Some(layer), top, image).unwrap(), doc.insert(Some(layer), top + 1, strip).unwrap());
+ let mut other = doc.clone();
+ other.node_mut(image).unwrap().transform(Affine::translate(Vec2::new(0.0, 20.0)), PAGE_MOVE);
+ other.node_mut(strip).unwrap().transform(Affine::translate(Vec2::new(-65.0, -30.0)), PAGE_MOVE);
+ let frame = frame_of(&page, false);
+ let mut view = PageView::new(&frame);
+ assert!(held_back(&frame, &mut view, &doc, &drawn, false).is_empty());
+ assert_eq!(held_back(&frame, &mut view, &other, &drawn, false), BTreeSet::from([drawn[0].0]));
+ let other = (other, 0);
+ let layers = Placements { doc: &doc, unseen: 0, other: Some(&other), drawn: &drawn, places: &[(drawn[0].0, 0)] };
+ let mut free = None;
+ let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free);
+ assert_eq!(free, Some(vec![0]), "the layers as read don't hold the envelope");
+ let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") };
+ assert!(notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}");
+ assert!(typed && kept.node(drawn[0].0).is_none(), "the envelope keeps its drawn art at the other placement");
+ }
+
+ /// Where putting back only the envelopes of type the page holds makes the layers draw as the page,
+ /// though putting back every live envelope's drawn art doesn't (an envelope of type the page
+ /// doesn't hold has stale drawn art), the layers are used as they then are: nothing differs to
+ /// come in as the page's art.
+ #[test]
+ fn layers_that_draw_as_the_page_with_the_held_envelopes_of_type_put_back_are_used_so() {
+ let (held, kept_live) = (Rect::new(10.0, 10.0, 30.0, 30.0), Rect::new(60.0, 10.0, 80.0, 30.0));
+ let page = black_page(&[held, kept_live]);
+ // The first is drawn 6 points off its drawn art, which the page draws; the second draws as the
+ // page, its drawn art 6 points off.
+ let (doc, drawn) = stand_ins(&[Rect::new(16.0, 10.0, 36.0, 30.0), kept_live], &[held, Rect::new(66.0, 10.0, 86.0, 30.0)]);
+ let frame = frame_of(&page, false);
+ let mut view = PageView::new(&frame);
+ assert!(compare(&frame, &mut view, &doc, false).is_err());
+ let mut all = doc.clone();
+ all.insert_beside(drawn.iter().map(|(id, art, _)| (*id, art.clone())).collect(), true).unwrap();
+ assert!(compare(&frame, &mut view, &all, false).is_err(), "with every drawn art put back they don't draw as the page");
+ let places = [(drawn[0].0, 0), (drawn[1].0, 1)];
+ let layers = Placements { doc: &doc, unseen: 0, other: None, drawn: &drawn, places: &places };
+ let mut free = None;
+ let found = keep_differing_typed(&frame, &mut view, &layers, &BTreeSet::new(), &[], false, &mut free);
+ assert_eq!(free, Some(vec![1]));
+ let Some((kept, _, notes, typed)) = found else { panic!("the layers aren't used") };
+ assert!(typed && kept.node(drawn[0].0).is_none() && kept.node(drawn[1].0).is_some());
+ assert!(!notes.iter().any(|n| n.starts_with(DREW_DIFFERENTLY)), "{notes:?}");
+ }
}
diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs
index 34f458f328..424ac4079a 100644
--- a/crates/eps/src/import/native/compare.rs
+++ b/crates/eps/src/import/native/compare.rs
@@ -570,6 +570,34 @@ impl PageView {
}
}
}
+
+ /// Which pixels of each of the pages `on` `art` inks (draws over with an alpha above 8 of 255),
+ /// drawn as the layers are drawn on them, row by row.
+ pub(super) fn inked(&mut self, art: &Document, on: &[usize]) -> Vec<(usize, Vec)> {
+ self.draw(art, on)
+ .into_iter()
+ .map(|(i, drawn)| (i, drawn.pixels.as_chunks::<4>().0.iter().map(|p| u32::from(p[3]) > WHITE).collect()))
+ .collect()
+ }
+
+ /// Of the pixels of page `i` that `marks` marks (row by row), how many `b` draws differently from
+ /// the page, as [`compared`] counts them: those that differ (with `reflow`, those away from type),
+ /// and those that differ with all their neighbors, away from type. `None` when `marks` isn't the
+ /// page's size.
+ pub(super) fn differing_in(&mut self, b: &Document, i: usize, marks: &[bool], reflow: bool) -> Option<(usize, usize)> {
+ self.layered(b, &[i]);
+ let printed = as_printed(b);
+ let b = printed.as_ref().unwrap_or(b);
+ let page = self.pages.get(i)?;
+ let layered = self.drawn.last()?.pages.get(i)?.as_ref()?;
+ let (differs, typed, solid) = differing_pixels(&page.drawn, &layered.pixels, &page.counted_type(b, page.region, reflow), &page.skipped())?;
+ if marks.len() != differs.len() {
+ return None;
+ }
+ let any = marks.iter().zip(&differs).zip(&typed).filter(|((m, d), t)| **m && **d && !(reflow && **t)).count();
+ let solid = marks.iter().zip(&solid).filter(|(m, s)| **m && **s).count();
+ Some((any, solid))
+ }
}
/// The `w` × `h` pixels of `from` from pixel (`x`, `y`): `None` unless they lie in it, from a pixel.
@@ -682,8 +710,27 @@ pub(super) fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render:
/// [`compared`], and which of the pixels (row by row) are counted in its `solid` share: none when
/// the drawings can't be compared.
fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> (Difference, Vec) {
- if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
+ let Some((differs, typed, solid)) = differing_pixels(x, y, areas, skip) else {
return (Difference { any: 1.0, art: 1.0, solid: 1.0, typed: 0.0 }, vec![]);
+ };
+ let area = (f64::from(x.width) * f64::from(x.height)).max(1.0);
+ let any = differs.iter().filter(|d| **d).count() as f64 / area;
+ let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count() as f64 / area;
+ let shares = Difference { any, art, solid: solid.iter().filter(|s| **s).count() as f64 / area, typed: any - art };
+ (shares, solid)
+}
+
+/// Which pixels (row by row) of two drawings of the same size differ ([`differ`], those in `skip`
+/// counting as alike), which of those lie in the type's `areas`, and which differ with all their
+/// neighbors away from type: [`compared`]'s pixels. `None` when the drawings can't be compared.
+fn differing_pixels(
+ x: &vectorcraft_render::Rendered,
+ y: &vectorcraft_render::Rendered,
+ areas: &[Rect],
+ skip: &[Rect],
+) -> Option<(Vec, Vec, Vec)> {
+ if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
+ return None;
}
let (w, h) = (x.width as usize, x.height as usize);
// Pixels in `skip` count as alike.
@@ -691,10 +738,9 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render:
let differs: Vec =
x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect();
if !differs.contains(&true) {
- return (Difference { any: 0.0, art: 0.0, solid: 0.0, typed: 0.0 }, vec![false; w * h]);
+ return Some((differs, vec![false; w * h], vec![false; w * h]));
}
- let typed = in_areas(areas, w, h);
- let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count();
+ let typed: Vec = in_areas(areas, w, h).into_iter().zip(&differs).map(|(t, d)| t && *d).collect();
// Which pixels differ with their left and right neighbors.
let mut across = vec![false; w * h];
for (k, a) in across.iter_mut().enumerate() {
@@ -706,11 +752,7 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render:
for (k, s) in solid.iter_mut().enumerate().skip(w).take(w * h.saturating_sub(2)) {
*s = !typed.get(k).copied().unwrap_or(true) && [k - w, k, k + w].iter().all(|n| across.get(*n).copied().unwrap_or(false));
}
- let area = (w as f64 * h as f64).max(1.0);
- let any = differs.iter().filter(|d| **d).count() as f64 / area;
- let art = art as f64 / area;
- let shares = Difference { any, art, solid: solid.iter().filter(|s| **s).count() as f64 / area, typed: any - art };
- (shares, solid)
+ Some((differs, typed, solid))
}
/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin
@@ -879,7 +921,7 @@ const NOTABLE_DIFFERENCE: f64 = 0.002;
pub(in crate::import) const MAX_SOLID_DIFFERENCE: f64 = 0.001;
/// How far (points) the art the layers print may reach past a page that holds it all.
-const PAST_THE_PAGE: f64 = 1.0;
+pub(super) const PAST_THE_PAGE: f64 = 1.0;
/// Do the layers' art and the page's own look alike (where the page holds all the art that prints,
/// also round it), on every page the frame has ([`Frame::more`])? An error says they don't; `Ok` has
diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs
index ee39aec1f1..d1af3301df 100644
--- a/crates/eps/src/import/native/layers.rs
+++ b/crates/eps/src/import/native/layers.rs
@@ -1,4 +1,4 @@
-use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_without_page, reid_page_art};
+use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art};
use super::slots::LAYERS_UNREAD;
use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai};
@@ -177,15 +177,27 @@ pub fn layered_ai_from(
add_notes(&mut all, read.notes());
(visible, all, false)
};
- let built = structure
- .unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new))
- .and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit));
+ // The envelopes of type the page doesn't hold against layers that don't draw as it, and how many
+ // envelopes of type the layers had.
+ let (mut free, mut count) = (None, 0);
+ let built = structure.unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new)).and_then(|structure| {
+ count = structure.warped_type.envelope_count();
+ build_weighing(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit, &mut free)
+ });
// Layers that waited on the page only for their envelopes of type open alone, as they would
- // have without them, each envelope keeping its drawn art, unless one opened live with the page.
- let live = matches!(&built, Ok((done, _)) if holds_type_envelope(&done.layers));
+ // have without them, unless one opened live with the layers drawing as the page. Alone, each
+ // envelope keeps its drawn art but those the page didn't hold against the layers, which open
+ // live as they were weighed. They were weighed on the layers as compared with the page, before
+ // any object of them came in as the page's art: those layers aren't used here, whatever came of
+ // them.
+ let live = free.is_none() && matches!(&built, Ok((done, _)) if holds_type_envelope(&done.layers));
if !live
&& let Some((mut done, notes)) = alone.and_then(|(pages, unit)| {
- let structure = ai::read_over_page(&data, false, names).ok()?;
+ let mut structure = ai::read_over_page(&data, false, names).ok()?;
+ // With none free, nothing needs settling again: every envelope keeps its drawn art.
+ if let Some(free) = free.as_deref().filter(|free| !free.is_empty()) {
+ structure.warped_type.open_alone(free, count);
+ }
build_without_page(structure, &data, &pages, unit).ok()?.ok()
})
{
diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs
index c7cdf20e11..1f634b3744 100644
--- a/crates/eps/src/import/tests_native.rs
+++ b/crates/eps/src/import/tests_native.rs
@@ -5528,6 +5528,11 @@ fn type_envelope(keys: &str, drawn: &str) -> String {
/// [`type_envelope`], with `after` (on plain lines, written on `%_` ones) after the text object in
/// its contents, such as the text object's XML id.
fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String {
+ type_envelope_of(0, keys, drawn, after)
+}
+
+/// [`type_envelope_with`] around the type of story `story`.
+fn type_envelope_of(story: u32, keys: &str, drawn: &str, after: &str) -> String {
let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n");
let mesh = format!(
"/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n",
@@ -5536,18 +5541,23 @@ fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String {
corner(80.0, 70.0, 80.0, 60.0, 70.0, 70.0),
corner(80.0, 40.0, 70.0, 40.0, 80.0, 50.0),
);
- type_envelope_on(keys, &mesh, drawn, after)
+ type_envelope_on_story(story, keys, &mesh, drawn, after)
}
/// [`type_envelope_with`], with `shape` (on plain lines, written on `%_` ones) in the group before
/// its dictionary, where the editing data saves the envelope's mesh.
fn type_envelope_on(keys: &str, shape: &str, drawn: &str, after: &str) -> String {
+ type_envelope_on_story(0, keys, shape, drawn, after)
+}
+
+/// [`type_envelope_on`] around the type of story `story`.
+fn type_envelope_on_story(story: u32, keys: &str, shape: &str, drawn: &str, after: &str) -> String {
let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() };
format!(
"{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n",
hidden(&format!("u\n{shape}U\n")),
hidden(&format!("/ArtDictionary :\n{keys}")),
- hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(0))),
+ hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(story))),
)
}
@@ -5594,7 +5604,12 @@ fn arc_envelope(text: &Node, bend: f64) -> Node {
/// The type of the file with an envelope of type whose drawn art is `drawn`, read with its text
/// document `doc`: the hidden text object it keeps, as the reader makes it.
fn made_envelope_type(doc: &str) -> Node {
- let art = native_with_text(&layer("Art", true, &type_envelope(&arc_keys(0.3), &format!("{}\n", square(10, 10)))), doc);
+ made_envelope_type_of(doc, 0)
+}
+
+/// [`made_envelope_type`] of story `story`.
+fn made_envelope_type_of(doc: &str, story: u32) -> Node {
+ let art = native_with_text(&layer("Art", true, &type_envelope_of(story, &arc_keys(0.3), &format!("{}\n", square(10, 10)), "")), doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let mut text = None;
r.document.walk(|n| {
@@ -5685,9 +5700,9 @@ fn an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it() {
/// A `.ai` whose layers would open alone, without its PDF part, waits on the PDF part when an
/// envelope of type is in them, as for the other live envelopes: one opens live only where the
-/// layers with it draw as the page. Where they don't, they open alone as before, the envelope
-/// keeping its drawn art and its type, hidden, with the note, even though it would draw as that
-/// drawn art; where they do, it opens live (#3179).
+/// layers with it draw as the page. Where they don't, the page drawing nothing where the envelope
+/// is, they open alone as before, the envelope keeping its drawn art and its type, hidden, with the
+/// note, even though it would draw as that drawn art; where they do, it opens live (#3179).
#[test]
fn an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone() {
let Some(family) = installed_family() else { return };
@@ -5751,9 +5766,10 @@ fn an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live() {
}
/// Layers used though they draw differently from the page (the editing data trusted) leave an
-/// envelope of type its drawn art and its type, hidden, with the note, even where it draws as that
-/// drawn art: one opens live only where the layers with it draw as the page. A `.ai` whose layers
-/// would open alone then opens alone, as it would without the envelope (#3179).
+/// envelope of type its drawn art and its type, hidden, with the note, where the page draws
+/// nothing where the envelope is, even where it draws as that drawn art: one opens live only where
+/// the layers with it draw as the page there. A `.ai` whose layers would open alone then opens
+/// alone, as it would without the envelope (#3179).
#[test]
fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art() {
let Some(family) = installed_family() else { return };
@@ -5789,19 +5805,27 @@ fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_dr
assert!(!notes.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{notes:?}");
}
-/// Layers whose objects that draw differently from the page come in as its art leave an envelope
-/// of type its drawn art and its type, hidden, with the note, even where the envelope draws as the
-/// page: the layers with it don't (#3179).
-#[test]
-fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art() {
- let Some(family) = installed_family() else { return };
- let doc = type_envelope_text(&family, None);
- let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
- // A square black in the layers and cyan on the page, away from the envelope.
+/// The layers and the page of a file with an envelope of type that draws as its drawn art, and a
+/// square black in the layers and cyan on the page, away from the envelope; the page draws the
+/// envelope bent by `page_bend` (percent).
+fn envelope_beside_a_differing_square(family: &str, page_bend: f64) -> (String, String) {
+ let doc = type_envelope_text(family, None);
+ let text = made_envelope_type(&doc);
+ let (drawn, _) = drawn_envelope(&arc_envelope(&text, 30.0));
+ let (_, page) = drawn_envelope(&arc_envelope(&text, page_bend));
let body = format!("{}\n{}\n{}", square(10, 10), square_16(76, BLACK), type_envelope(&arc_keys(0.3), &drawn));
- let art = native_with_text(&layer("Art", true, &body), &doc);
let cyan = "1 0 0 0 setcmykcolor 76 10 moveto 92 10 lineto 92 26 lineto 76 26 lineto closepath fill";
- let r = import(&eps(&format!("{}\n{cyan}\n{page}", page_square(10, 10)), &art)).unwrap();
+ (native_with_text(&layer("Art", true, &body), &doc), format!("{}\n{cyan}\n{page}", page_square(10, 10)))
+}
+
+/// Layers whose objects that draw differently from the page come in as its art keep an envelope of
+/// type live where the envelope draws as the page: the art that differs isn't the envelope's, and
+/// the layers with it then draw as the page (#3179).
+#[test]
+fn layers_with_objects_that_come_in_as_the_page_art_keep_an_envelope_of_type_that_draws_as_the_page_live() {
+ let Some(family) = installed_family() else { return };
+ let (art, page) = envelope_beside_a_differing_square(&family, 30.0);
+ let r = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert!(
r.warnings.iter().any(|n| n.starts_with("objects that drew differently from the file's own page came in as its art")),
@@ -5809,12 +5833,169 @@ fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_it
r.warnings
);
let (envelopes, loose) = envelopes_and_type(&r.document);
+ assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings);
+ assert!(loose.is_empty(), "{loose:?}");
+ assert!(!r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings);
+}
+
+/// Layers used though they draw differently from the page (the editing data trusted) leave an
+/// envelope of type its drawn art and its type, hidden, with the note, where the page draws it
+/// otherwise than they do (bent the other way), though other art differs too: the page holds the
+/// envelope against them where it is (#3179).
+#[test]
+fn trusted_layers_leave_an_envelope_of_type_the_page_draws_otherwise_its_drawn_art() {
+ let Some(family) = installed_family() else { return };
+ let (art, page) = envelope_beside_a_differing_square(&family, -30.0);
+ let r = crate::import_trusting(&eps(&page, &art), true, true).unwrap();
+ assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
+ assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", r.warnings);
+ let (envelopes, loose) = envelopes_and_type(&r.document);
assert!(envelopes.is_empty(), "{envelopes:?}");
assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings);
- assert_eq!(loose_type_names(&r.document), [None]);
assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings);
}
+/// Layers used though they draw differently from the page (the editing data trusted) keep an
+/// envelope of type live where the page draws it as they do and the art that differs is elsewhere
+/// (#3179).
+#[test]
+fn trusted_layers_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page() {
+ let Some(family) = installed_family() else { return };
+ let (art, page) = envelope_beside_a_differing_square(&family, 30.0);
+ let r = crate::import_trusting(&eps(&page, &art), true, true).unwrap();
+ assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
+ assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", r.warnings);
+ let (envelopes, loose) = envelopes_and_type(&r.document);
+ assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings);
+ assert!(loose.is_empty(), "{loose:?}");
+ assert!(!r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings);
+}
+
+/// A `.ai` whose layers would open alone, and that waits on its PDF part for an envelope of type,
+/// opens alone with the envelope live where the page draws it as the layers do, though the page
+/// also draws art the layers don't have: the envelope's drawn art put back wouldn't make the layers
+/// draw as the page (#3179).
+#[test]
+fn layers_that_would_open_alone_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page() {
+ let Some(family) = installed_family() else { return };
+ let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)];
+ let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})"));
+ // [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split.
+ while ascii85(doc.as_bytes()).len() % 60 == 1 {
+ doc.push('\n');
+ }
+ let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
+ let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn));
+ let file = private(&native_with_text(&layer("Art", true, &art), &doc));
+ let names = crate::NameEncoding::default();
+ let open = |page: Document| {
+ let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") };
+ crate::layered_ai_from(layers, page, vec![], false, false, 1.0, names)
+ };
+ // The layers alone, with the envelope's drawn art: what the page draws.
+ let (mut page, notes, _) = open(pdf_part(80.0, 80.0));
+ assert!(envelopes_and_type(&page).0.is_empty(), "{notes:?}");
+ // And a black square the layers don't have, away from the type.
+ add_black_square(&mut page, 80.0, 4.0);
+ let (d, notes, read) = open(page);
+ assert!(read, "{notes:?}");
+ let (envelopes, loose) = envelopes_and_type(&d);
+ assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{notes:?}");
+ assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}");
+ assert!(!notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}");
+}
+
+/// A black square of 16 points at (`x`, `y`) put on top of `page`'s first layer.
+fn add_black_square(page: &mut Document, x: f64, y: f64) {
+ let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
+ let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint));
+ let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 16.0, y + 16.0));
+ let square = Node::path(page.alloc_id(), path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() });
+ let layer = page.layers[0].id;
+ page.insert(Some(layer), 0, square).unwrap();
+}
+
+/// A `.ai` whose layers would open alone, with the editing data trusted, opens alone with an
+/// envelope of type live where the page draws it as the layers do, though the page also draws art
+/// they don't have: not from the layers compared with the page, which would say they come from the
+/// editing data all the same (#3179).
+#[test]
+fn trusted_layers_that_would_open_alone_open_alone_with_an_envelope_of_type_the_page_doesnt_hold_live() {
+ let Some(family) = installed_family() else { return };
+ let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)];
+ let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})"));
+ while ascii85(doc.as_bytes()).len() % 60 == 1 {
+ doc.push('\n');
+ }
+ let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
+ let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn));
+ let file = private(&native_with_text(&layer("Art", true, &art), &doc));
+ let names = crate::NameEncoding::default();
+ let open = |page: Document, trust: bool| {
+ let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") };
+ crate::layered_ai_from(layers, page, vec![], false, trust, 1.0, names)
+ };
+ let (mut page, _, _) = open(pdf_part(80.0, 80.0), false);
+ add_black_square(&mut page, 80.0, 4.0);
+ let (d, notes, read) = open(page, true);
+ assert!(read, "{notes:?}");
+ let (envelopes, loose) = envelopes_and_type(&d);
+ assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{notes:?}");
+ assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}");
+ assert!(notes.is_empty(), "{notes:?}");
+}
+
+/// Of two envelopes of type in a `.ai` whose layers would open alone, the one the page holds
+/// against the layers (it draws nothing where the envelope is) keeps its drawn art and its type,
+/// hidden, with the note, and the other opens live: each by its place among the envelopes of type
+/// as the layers are read, both times they are (#3179).
+#[test]
+fn layers_that_would_open_alone_keep_live_only_the_envelopes_of_type_the_page_doesnt_hold() {
+ let Some(family) = installed_family() else { return };
+ let stories = [
+ ("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0),
+ ("HILL\r", 0, (8170.0, 8200.0), (0.0, 0.0), 0.0),
+ ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0),
+ ];
+ let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})"));
+ while ascii85(doc.as_bytes()).len() % 60 == 1 {
+ doc.push('\n');
+ }
+ let drawn = |story| drawn_envelope(&arc_envelope(&made_envelope_type_of(&doc, story), 30.0)).0;
+ let art = format!(
+ "u\n{}U\n{}{}",
+ text_object(2),
+ type_envelope_of(0, &arc_keys(0.3), &drawn(0), ""),
+ type_envelope_of(1, &arc_keys(0.3), &drawn(1), "")
+ );
+ let file = private(&native_with_text(&layer("Art", true, &art), &doc));
+ let names = crate::NameEncoding::default();
+ let open = |page: Document| {
+ let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") };
+ crate::layered_ai_from(layers, page, vec![], false, false, 1.0, names)
+ };
+ // The layers alone, with the drawn art of both envelopes, but the first's: the page draws
+ // nothing where the first is.
+ let (mut page, notes, _) = open(pdf_part(80.0, 80.0));
+ let top = page.layers[0].clone();
+ let first = top
+ .children()
+ .into_iter()
+ .flatten()
+ .filter(|n| matches!(n.kind, NodeKind::Group { .. }))
+ .min_by(|a, b| a.visual_bounds().map_or(f64::MAX, |r| r.y0).total_cmp(&b.visual_bounds().map_or(f64::MAX, |r| r.y0)))
+ .map(|n| n.id)
+ .unwrap_or_else(|| panic!("no drawn art: {notes:?}"));
+ page.remove(first).unwrap();
+ let (d, notes, read) = open(page);
+ assert!(read, "{notes:?}");
+ let (envelopes, mut loose) = envelopes_and_type(&d);
+ assert_eq!(envelopes, [vec!["HILL".to_string()]], "{notes:?}");
+ loose.sort();
+ assert_eq!(loose, [("FLUX".to_string(), false), ("Note".to_string(), true)], "{notes:?}");
+ assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}");
+}
+
/// An envelope of type whose type a visibility variable binds keeps its drawn art, as one whose type
/// a text variable binds does: the binding stays the hidden type's, which the live envelope would
/// take the place of (#3179).
diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs
index 042a7ed2d2..00bb29d12f 100644
--- a/crates/eps/src/legacy_ai.rs
+++ b/crates/eps/src/legacy_ai.rs
@@ -115,6 +115,7 @@ const CONIC: &str = "conic gradients are written as solid wedges clipped to the
const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)";
const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (legacy .ai gradients only pad)";
const SPREAD_CAPPED: &str = "gradients that repeat or reflect are written with at most 256 stops; they pad past those";
+const HUE_STOPS: &str = "gradients mixed around the hue wheel are written in process colors, with the colors between their stops as stops of their own (at most 256; legacy .ai gradients mix their stops' colors directly)";
/// Wedges a conic gradient is written as: one a degree.
const CONIC_WEDGES: usize = 360;
const STROKES: &str =
diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs
index 76e96f5aa9..6b2f8335b8 100644
--- a/crates/eps/src/legacy_ai/tests.rs
+++ b/crates/eps/src/legacy_ai/tests.rs
@@ -198,6 +198,46 @@ fn many_repeats_are_written_within_the_stops_the_reader_reads() {
assert_eq!(stops, Some(written), "every stop written is read");
}
+/// The format's gradients mix their stops' colors directly: a gradient mixed around the hue wheel
+/// is written with the colors on its way around the wheel as stops, no more than the reader
+/// reads, with a warning, and opens with the colors it had.
+#[test]
+fn hue_wheel_gradients_are_written_with_the_stops_on_their_curve() {
+ use vectorcraft_color::GradientMethod;
+ for (method, middle) in [(GradientMethod::HueCounterclockwise, [0.0, 1.0, 0.0]), (GradientMethod::HueClockwise, [1.0, 0.0, 1.0])] {
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let g = GradientPaint::new(Gradient { method, ..Gradient::new(GradientKind::Linear, stops) });
+ let want = g.gradient.clone();
+ let mut doc = Document::new(200.0, 100.0);
+ let n = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None);
+ let l = doc.layers[0].id;
+ doc.insert(Some(l), 0, n).unwrap();
+ let out = write(&doc);
+ assert!(out.warnings.iter().any(|w| w == HUE_STOPS), "{method:?}: {:?}", out.warnings);
+ let text = String::from_utf8_lossy(&out.bytes).into_owned();
+ let def = text.split("%AI5_BeginGradient: (Gradient)\n").nth(1).and_then(|d| d.split("%AI5_EndGradient").next()).unwrap();
+ let written = def.matches(" %_Bs\n").count();
+ // The two ends and each sixth of the wheel between them, where the way turns in RGB.
+ assert!((3..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{method:?}: {written} stops");
+ let read = crate::import(&out.bytes).unwrap().document;
+ let mut back = None;
+ read.walk(|n| {
+ if let (None, Paint::Gradient(g)) = (&back, n.appearance.fill_paint()) {
+ back = Some(g.gradient.clone());
+ }
+ });
+ let back = back.unwrap();
+ assert!(back.method.is_classic(), "{method:?}");
+ let mid = back.sample(0.5).0.to_rgb();
+ assert!(mid.iter().zip(middle).all(|(a, b)| (a - b).abs() < 0.03), "{method:?}: the middle is {mid:?}");
+ for k in 0..=100 {
+ let t = k as f32 / 100.0;
+ let (a, b) = (want.sample(t).0.to_rgb(), back.sample(t).0.to_rgb());
+ assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.03), "{method:?} at {t}: {a:?} vs {b:?}");
+ }
+ }
+}
+
/// A radial gradient repeated out from a center far from the shape is written with its ramp points
/// (percent) to five decimals, so its runs keep their places hundreds of runs out.
#[test]
diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs
index 06d9e0ddcc..20f8b32b96 100644
--- a/crates/eps/src/legacy_ai/writer.rs
+++ b/crates/eps/src/legacy_ai/writer.rs
@@ -9,7 +9,7 @@ use super::{
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
};
-use super::{CONIC, CONIC_WEDGES, DIAMOND, SPREAD, SPREAD_CAPPED};
+use super::{CONIC, CONIC_WEDGES, DIAMOND, HUE_STOPS, SPREAD, SPREAD_CAPPED};
pub(super) struct Writer<'a> {
doc: &'a Document,
@@ -693,6 +693,16 @@ impl<'a> Writer<'a> {
/// (its distance a fraction of the radius, at most 1, §3.5.5; `0 0 0 0 Bh` at the center,
/// §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`.
fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String {
+ // A gradient mixed around the hue wheel (the format's gradients mix their stops' colors
+ // directly): the stops on its curve, no more than the reader reads, in a definition of
+ // its own.
+ if g.gradient.method.is_hue() {
+ self.warn(HUE_STOPS);
+ let curve = g.gradient.as_classic_curve();
+ let gradient = if curve.is_spread() { curve } else { curve.fitted(crate::import::MAX_GRADIENT_STOPS).into_owned() };
+ let p = GradientPaint { gradient, swatch: None, ..g.clone() };
+ return self.gradient(&p, bounds);
+ }
// A gradient that repeats or reflects (the format's gradients pad past their ends): its
// stops repeated as many times as `bounds` needs, in a definition of its own, no more
// stops than the reader reads.
diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs
index 0ec561dd31..c20c5847a2 100644
--- a/crates/eps/src/scene.rs
+++ b/crates/eps/src/scene.rs
@@ -42,6 +42,7 @@ const SPOT_GRADIENTS_L2: &str =
"gradients mixing a spot color with other inks are written in process colors at PostScript Level 2 (Level 3 keeps the spot inks)";
const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a DeviceN color space are written without the spot inks";
const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors are written in process colors";
+const SPOT_GRADIENTS_HUE: &str = "gradients of spot colors mixed around the hue wheel are written in process colors";
const CONIC_WEDGES: &str = "conic gradients are written as solid wedges (EPS has no conic shading)";
const DIAMOND_QUADRANTS: &str = "diamond gradients are written as four linear gradients (EPS has no diamond shading)";
const SPREAD_CAPPED: &str =
@@ -913,8 +914,8 @@ impl<'a> Scene<'a> {
/// them. Paper white adds no ink, a gray stop is black ink, and a spot stop's process plates
/// print with its ink. An off-center midpoint is a stop
/// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and,
- /// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method;
- /// it is then written in process colors.
+ /// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method
+ /// or around the hue wheel; it is then written in process colors.
fn ink_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<(GradientSpace, RawStops)> {
let doc = self.doc;
let spot_of = |s: &vectorcraft_color::GradientStop| -> Option {
@@ -936,7 +937,7 @@ impl<'a> Scene<'a> {
return None;
}
if !g.method.is_classic() {
- self.warn(SPOT_GRADIENTS_PERCEPTUAL);
+ self.warn(if g.method.is_hue() { SPOT_GRADIENTS_HUE } else { SPOT_GRADIENTS_PERCEPTUAL });
return None;
}
if names.len() > MAX_DEVICE_N {
@@ -1093,6 +1094,14 @@ impl<'a> Scene<'a> {
/// both levels; a diamond: four linear gradients, [`Self::diamond_quadrants`]).
fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) {
let geom = g.resolve(bounds);
+ // One mixed around the hue wheel that reflects is repeated from its curve's stops, which
+ // keep no swatch links: the warning [`Self::ink_stops`] gives the others.
+ if g.gradient.method.is_hue() && g.gradient.is_spread() && g.gradient.spread == vectorcraft_color::GradientSpread::Reflect {
+ let doc = self.doc;
+ if g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) {
+ self.warn(SPOT_GRADIENTS_HUE);
+ }
+ }
// A linear, radial or diamond gradient that repeats or reflects: PostScript's shadings
// pad past their ends, so its stops are repeated as many times as the area needs.
if let Some(padded) = g.gradient.padded_over(&geom, area) {
diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs
index 0d5649ead5..c98cb9689a 100644
--- a/crates/eps/src/tests.rs
+++ b/crates/eps/src/tests.rs
@@ -673,6 +673,53 @@ fn perceptual_gradients_of_spot_inks_are_written_in_process_colors() {
assert!(text(&export(&d, &opts(&d), None, None).unwrap()).contains("/ColorSpace VCdn0"));
}
+/// The colors between two stops mixed around the hue wheel are not mixes of the stops' inks: a
+/// gradient of spot inks mixed that way is written in process colors, with its own warning, as
+/// the same gradient without spot inks is.
+#[test]
+fn hue_wheel_gradients_of_spot_inks_are_written_in_process_colors() {
+ for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
+ let stops = vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, "Leaf Ink", LEAF, 1.0)];
+ let mut hue = Gradient::new(GradientKind::Linear, stops.clone());
+ hue.method = method;
+ // Reflecting, it is written from the stops on its curve, and warns all the same.
+ let reflected = Gradient { spread: vectorcraft_color::GradientSpread::Reflect, repeats: 2, ..hue.clone() };
+ let r = spot_gradient_doc_of(reflected);
+ let reflected_out = export(&r, &opts(&r), None, None).unwrap();
+ let reflected_page = checked_page(&text(&reflected_out));
+ assert!(!reflected_page.contains("VCdn") && !reflected_page.contains("VCsp"), "{method:?} reflected: {reflected_page}");
+ assert!(
+ reflected_out.warnings.iter().any(|w| w.contains("mixed around the hue wheel are written in process colors")),
+ "{method:?} reflected: {:?}",
+ reflected_out.warnings
+ );
+ let d = spot_gradient_doc_of(hue);
+ let out = export(&d, &opts(&d), None, None).unwrap();
+ let page = checked_page(&text(&out));
+ assert!(!page.contains("VCdn") && !page.contains("VCsp"), "{method:?}: {page}");
+ assert!(page.contains("/ColorSpace /DeviceCMYK"), "{method:?}: {page}");
+ assert!(
+ out.warnings.iter().any(|w| w.contains("gradients of spot colors mixed around the hue wheel are written in process colors")),
+ "{method:?}: {:?}",
+ out.warnings
+ );
+ assert!(!out.warnings.iter().any(|w| w.contains("Perceptual")), "{method:?}: {:?}", out.warnings);
+ let process = |c: Color| GradientStop::new(0.0, c);
+ let mut plain = Gradient::new(GradientKind::Linear, vec![process(GOLD), GradientStop { offset: 1.0, ..process(LEAF) }]);
+ plain.method = method;
+ let p = spot_gradient_doc_of(plain);
+ let plain_out = export(&p, &opts(&p), None, None).unwrap();
+ assert!(!plain_out.warnings.iter().any(|w| w.contains("hue wheel")), "{method:?}: {:?}", plain_out.warnings);
+ let plain_page = checked_page(&text(&plain_out));
+ let shading = |s: &str| -> Option {
+ let i = s.find("/ShadingType")?;
+ Some(s[i..i + s[i..].find("shfill")?].to_string())
+ };
+ assert!(shading(&page).is_some_and(|s| s.contains("/Bounds")), "{method:?}: {page}");
+ assert_eq!(shading(&page), shading(&plain_page), "{method:?}");
+ }
+}
+
#[test]
fn rgb_is_written_as_cmyk_when_asked_and_in_cmyk_documents() {
let d = doc_with(|d| vec![red_rect(d)]);
diff --git a/crates/mcp/src/tests/exportas.rs b/crates/mcp/src/tests/exportas.rs
index f46956bab8..4e41002bf0 100644
--- a/crates/mcp/src/tests/exportas.rs
+++ b/crates/mcp/src/tests/exportas.rs
@@ -40,9 +40,21 @@ fn an_option_no_format_takes_is_an_error() {
assert!(e.contains(named), "{method}: {e}");
}
}
- // One object can still carry several formats' options.
+ // One object can still carry several formats' options; the warnings name those the file
+ // doesn't read.
let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": {"quality": 80, "styling": "style"}}));
assert!(!r.is_error, "{r:?}");
+ let warnings = text(&r)["warnings"].clone();
+ for key in ["quality", "styling"] {
+ assert!(warnings.as_array().unwrap().contains(&json!(format!("`{key}` was not used: PDF files don't read it"))), "{warnings}");
+ }
+ // `app.export` and `file.export` give the same warning.
+ let params = json!({"format": "pdf", "quality": 80});
+ let note = json!("`quality` was not used: PDF files don't read it");
+ let r = h.call("app.export", params.clone()).unwrap();
+ assert!(r["warnings"].as_array().unwrap().contains(¬e), "app.export: {r}");
+ let r = h.call("engine.execute", json!({"command": "file.export", "params": params})).unwrap();
+ assert!(r["warnings"].as_array().unwrap().contains(¬e), "file.export: {r}");
}
/// `app.save`, `file.save` and `file.exportForScreens` refuse a key no format takes before they
diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs
index df2627dfc2..8836b72ac2 100644
--- a/crates/mcp/src/ui_catalog.rs
+++ b/crates/mcp/src/ui_catalog.rs
@@ -1477,13 +1477,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.action.record",
"Begin Recording",
"",
- "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}",
+ "{set?: index, action?: index, after?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. With action, record into the action at action in set instead: ui.action.stop puts the recorded steps after step after of that action, else after its last step, and the steps after them keep whether they are excluded. The panel's Begin Recording button passes the selected action and its selected step. action without set, after without action, an index past the last set, action or step, an explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}, or with action {set, action, name}",
),
(
"ui.action.stop",
"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, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. 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 or the action ui.action.record named, at the place it named, 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. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}",
),
(
"ui.action.insertStop",
@@ -1521,6 +1521,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{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.action.moveStep",
+ "Move Action Step",
+ "",
+ "{set: index, action: index, step: index, toSet?: index, toAction?: index, before: index} move 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) into the action at toAction in toSet (default: the same set and action), before the step at index before there, or after its last step when before is its number of steps; before counts the steps as they are before the move. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays selected, and a moved selected step makes its new action the selected one and its set the current one. A Record Stop dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay after the steps that were before them. A step put back just before or just after itself does not move. A missing or invalid index, or a move that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. Undo does not move the step back. Dragging a step row in the panel runs this → {set, action, step, moved}",
+ ),
(
"ui.batchDialog",
"Batch…",
diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml
index a5a3b83863..d4394dfefa 100644
--- a/crates/pdf/Cargo.toml
+++ b/crates/pdf/Cargo.toml
@@ -30,6 +30,7 @@ craft-pdf = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
smallvec = "1"
+siphasher = "1"
weezl = "0.2"
serde = { workspace = true }
# The XMP packet that File Info is read from (file_info.rs); the version usvg already uses.
diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs
index acb96f1713..77815f93b3 100644
--- a/crates/pdf/src/export/paint.rs
+++ b/crates/pdf/src/export/paint.rs
@@ -463,7 +463,9 @@ impl Exporter<'_> {
/// than an image?
pub(super) fn perceptual_spot(&self, g: &vectorcraft_color::GradientPaint, fill: bool) -> bool {
let gr = &g.gradient;
- !gr.method.is_classic()
+ // Not one mixed around the hue wheel: its colors between two stops are not tints or
+ // mixes of the stops' inks, so it stays an image.
+ gr.method == vectorcraft_color::GradientMethod::Perceptual
&& gr.kind != GradientKind::Freeform
&& !(fill && gr.dither)
&& (self.one_spot_ink(gr).is_some() || self.device_n_inks_only(gr))
@@ -476,7 +478,7 @@ impl Exporter<'_> {
/// other gradients, and for an ink too light for lightness to tell its tints apart, which
/// [`Self::spot_stops`] then writes with the stops' own tints.
fn perceptual_spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> {
- if g.method.is_classic() {
+ if g.method != vectorcraft_color::GradientMethod::Perceptual {
return None;
}
let ink = self.one_spot_ink(g)?;
@@ -494,7 +496,7 @@ impl Exporter<'_> {
let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points();
if from_stops && self.has_spot_stop(&g.gradient) {
self.warn(
- "spot colors in gradients exported as images (freeform or dithered gradients, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors",
+ "spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors",
);
}
}
@@ -623,9 +625,9 @@ impl Exporter<'_> {
let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b)));
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
if g.gradient.kind == GradientKind::Conic {
- self.warn("conic gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), in a PDF/A file or with more than 97 stops are exported as images");
+ self.warn("conic gradients drawn as pixels (dithered fills, ones mixed around the hue wheel, and Perceptual ones other than spot ink shadings), in a PDF/A file or with more than 97 stops are exported as images");
} else if g.gradient.kind == GradientKind::Diamond {
- self.warn("diamond gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), with translucent stops or without an extent are exported as images");
+ self.warn("diamond gradients drawn as pixels (dithered fills, ones mixed around the hue wheel, and Perceptual ones other than spot ink shadings), with translucent stops or without an extent are exported as images");
} else if !freeform {
self.warn("linear and radial gradients drawn as pixels are exported as images");
}
diff --git a/crates/pdf/src/import/actual_text.rs b/crates/pdf/src/import/actual_text.rs
index 6d7ddbaf9d..aec147f99e 100644
--- a/crates/pdf/src/import/actual_text.rs
+++ b/crates/pdf/src/import/actual_text.rs
@@ -104,4 +104,35 @@ impl Builder<'_> {
}
true
}
+
+ /// A Type 3 glyph under an active ActualText span.
+ pub(super) fn actual_type3_glyph(&mut self, key: u128, at: Placement, look: Look, opacity: f32, advance: f64) -> bool {
+ self.flush_glyphs();
+ self.flush_type3();
+ if let (Some(stroke), Some(line)) = (&look.stroke, &mut self.text)
+ && look.fill.is_none()
+ && line.stroke_actual(key, at, stroke.clone(), look.overprint_stroke)
+ {
+ return true;
+ }
+ let pending = self.actual.pending.take();
+ let content = match pending.as_deref() {
+ Some(text) if !text.is_empty() => TextContent::Actual(text),
+ _ => TextContent::Pen,
+ };
+ let place = |line: &mut TextLine| line.push_content(&look, at, opacity, advance, content);
+ if !self.text.as_mut().is_some_and(place) {
+ self.flush_text();
+ let mut line = TextLine::new(at, opacity);
+ if !place(&mut line) {
+ self.actual.pending = pending;
+ return false;
+ }
+ self.text = Some(line);
+ }
+ if let Some(line) = &mut self.text {
+ line.has_type3 = true;
+ }
+ true
+ }
}
diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs
index 21d862d2fd..c1be8fba84 100644
--- a/crates/pdf/src/import/builder.rs
+++ b/crates/pdf/src/import/builder.rs
@@ -12,6 +12,18 @@ use super::{
Upright, Walked, clipped, contains, fold_stop_opacity, function_paint, gradient_shape, is_rectangle, luminance, mask_spec, shading_gradient,
};
+fn is_colour_face(face: &vectorcraft_text::FontFace) -> bool {
+ use skrifa::Tag;
+ use skrifa::raw::TableProvider;
+ let Ok(font) = skrifa::FontRef::new(face.file_data()) else {
+ return false;
+ };
+ font.colr().is_ok()
+ || font.table_data(Tag::new(b"SVG ")).is_some()
+ || font.table_data(Tag::new(b"CBDT")).is_some()
+ || font.table_data(Tag::new(b"sbix")).is_some()
+}
+
impl<'p> Builder<'p> {
pub(super) fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self {
Self {
@@ -22,6 +34,7 @@ impl<'p> Builder<'p> {
blend: BlendMode::Normal,
page: Rect::ZERO,
glyphs: None,
+ type3_run: None,
text: None,
text_glyphs: vec![],
text_as,
@@ -102,6 +115,7 @@ impl<'p> Builder<'p> {
self.root_groups.clear();
self.blend = BlendMode::Normal;
self.glyphs = None;
+ self.type3_run = None;
self.text = None;
self.text_glyphs.clear();
self.mask = None;
@@ -221,13 +235,48 @@ impl<'p> Builder<'p> {
}
}
- /// Finish the open glyph runs and the art drawn through the current soft mask.
+ /// Finish the open glyph runs, type 3 runs and the art drawn through the current soft mask.
pub(super) fn flush(&mut self) {
self.flush_glyphs();
+ self.flush_type3();
self.flush_text();
self.flush_masked();
}
+ /// Finish the open Type 3 font text run as a named group ``.
+ pub(super) fn flush_type3(&mut self) {
+ if let Some(run) = self.type3_run.take()
+ && !run.nodes.is_empty()
+ {
+ let Some(left) = self.objects_left.checked_sub(1) else {
+ self.warn(super::entry::TOO_MANY_OBJECTS);
+ return;
+ };
+ self.objects_left = left;
+ let mut g = Node::group(self.id(), run.nodes);
+ g.name = Some("".to_string());
+ self.emit(Arc::new(g), run.group);
+ }
+ }
+
+ /// Add nodes from an interpreted Type 3 glyph to the current Type 3 run.
+ pub(super) fn push_type3_nodes(&mut self, nodes: Vec>) {
+ if nodes.is_empty() {
+ return;
+ }
+ let group = self.current_group();
+ if let Some(run) = &mut self.type3_run {
+ if run.group != group {
+ self.flush_type3();
+ self.type3_run = Some(super::state::Type3Run { nodes, group });
+ } else {
+ run.nodes.extend(nodes);
+ }
+ } else {
+ self.type3_run = Some(super::state::Type3Run { nodes, group });
+ }
+ }
+
/// The art drawn through the graphics state's soft mask so far, as one masked object.
fn flush_masked(&mut self) {
if self.masked.is_empty() {
@@ -391,7 +440,7 @@ impl<'p> Builder<'p> {
let Some(mut line) = self.text.take() else { return false };
let held = std::mem::take(&mut line.held);
// Type the file names itself (ActualText) is in reading order as it stands.
- let checked = !glyphs.is_empty() && line.mapped();
+ let checked = !glyphs.is_empty() && line.mapped() && !line.has_type3;
let mut outlined = false;
if let Some((t, opacity, facts)) = line.finish() {
if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) {
@@ -509,23 +558,26 @@ impl<'p> Builder<'p> {
/// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the
/// coordinates `page` has. `None` when nested too deeply.
- fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option>> {
+ pub(super) fn nested(&mut self, page: Rect, blend: BlendMode, read: impl FnOnce(&mut Self)) -> Option>> {
if self.nested >= MAX_NESTED {
return None;
}
+ let outer_type3 = self.type3_run.take();
self.flush();
let saved = Saved {
stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]),
root_groups: std::mem::take(&mut self.root_groups),
- blend: std::mem::replace(&mut self.blend, BlendMode::Normal),
+ blend: std::mem::replace(&mut self.blend, blend),
mask: self.mask.take(),
page: std::mem::replace(&mut self.page, page),
+ type3_run: outer_type3,
};
self.nested += 1;
read(self);
let art = self.close_frames();
self.nested -= 1;
- (self.stack, self.root_groups, self.blend, self.mask, self.page) = (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page);
+ (self.stack, self.root_groups, self.blend, self.mask, self.page, self.type3_run) =
+ (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.type3_run);
Some(art)
}
@@ -537,7 +589,7 @@ impl<'p> Builder<'p> {
}
let page = self.page;
let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha);
- let art = self.nested(page, |b| m.interpret(b));
+ let art = self.nested(page, BlendMode::Normal, |b| m.interpret(b));
self.white_images = saved;
let spec = match art {
Some(art) => {
@@ -601,7 +653,7 @@ impl<'p> Builder<'p> {
// A swatch is shared with uses outside an alpha mask, and pattern paints keep their
// colours there: its images are read as they are.
let white = std::mem::take(&mut self.white_images);
- let art = self.nested(Rect::ZERO, |b| {
+ let art = self.nested(Rect::ZERO, BlendMode::Normal, |b| {
// A cell whose content can't be decoded is empty.
let _ = t.interpret(b, Affine::IDENTITY, stroke);
});
@@ -799,6 +851,125 @@ impl<'p> Builder<'p> {
self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint });
}
+ fn type3_font_name(&mut self, key: u128) -> FontInfo {
+ if let Some(n) = self.font_names.get(&key) {
+ return n.clone();
+ }
+ let name = self.fonts.get(&key).map(|f| f.name.clone());
+ let ps = name.as_deref().map(|n| match n.split_once('+') {
+ Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest.to_string(),
+ _ => n.to_string(),
+ });
+ let installed = ps.as_deref().and_then(|ps| vectorcraft_text::FontDb::global().find_postscript(ps));
+ let n = match (installed, name) {
+ (Some(face), _) => FontInfo { family: face.family.clone(), style: face.style.clone(), face: Some(face) },
+ (None, Some(name)) => {
+ let families = self.families.get_or_insert_with(Families::available);
+ let (family, style, found) = families.resolve(&name, None, false);
+ if !found && !family.is_empty() && !self.missing_fonts.contains(&family) {
+ self.missing_fonts.push(family.clone());
+ }
+ let face = vectorcraft_text::FontDb::global().face(&family, &style);
+ FontInfo { family, style, face }
+ }
+ (None, None) => {
+ let d = vectorcraft_doc::CharStyle::default();
+ FontInfo { family: d.font_family, style: d.font_style, face: None }
+ }
+ };
+ self.font_names.insert(key, n.clone());
+ n
+ }
+
+ /// Type 3 glyph as live type: added to the line being gathered if it has a ToUnicode mapping.
+ pub(super) fn type_type3_glyph(
+ &mut self,
+ t3: &craft_pdf::interpret::font::Type3Glyph<'_>,
+ m: Affine,
+ scale: f64,
+ paint: Option<&craft_pdf::interpret::Paint<'_>>,
+ stroke: Option<&StrokeProps>,
+ overprint: bool,
+ ) -> bool {
+ if self.text_as != TextAs::Text || self.nested > 0 {
+ return false;
+ }
+ let t3_key = craft_pdf::interpret::CacheKey::cache_key(t3);
+ let Some(&(font_key, has_to_unicode)) = self.scan.type3_cache_map.get(&t3_key) else {
+ return false;
+ };
+ let (is_type3, to_unicode, is_colour) = match self.fonts.get(&font_key) {
+ Some(f) => (f.is_type3, has_to_unicode || f.to_unicode, f.is_colour),
+ None => (true, has_to_unicode, false),
+ };
+ let authored = self.actual.active();
+ if !is_type3 || (!to_unicode && !authored) || is_colour {
+ return false;
+ }
+ let unicode = t3.as_unicode().filter(|u| !super::cid::replacement(u));
+ if !authored && unicode.is_none() {
+ return false;
+ }
+ let text: String = match unicode {
+ Some(BfString::Char(c)) => c.to_string(),
+ Some(BfString::String(s)) => s,
+ None => String::new(),
+ };
+ let Some(at) = Placement::of(m) else { return false };
+ if !authored && (text.is_empty() || text.chars().any(|c| c.is_control())) {
+ return false;
+ }
+ let width = t3.advance_width().filter(|w| w.is_finite());
+ let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
+ let info = self.type3_font_name(font_key);
+ if info.face.as_ref().is_some_and(|f| is_colour_face(f)) {
+ return false;
+ }
+ let (paint, opacity) = match paint {
+ Some(p) => {
+ let (paint, opacity) = self.paint(p, stroke.is_some(), self.walked(true));
+ (Some(paint), opacity)
+ }
+ None => (None, 1.0),
+ };
+ let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale)));
+ let look = Look {
+ font: font_key,
+ family: info.family,
+ style: info.style,
+ version: None,
+ size: at.size,
+ h_scale: at.h_scale,
+ overprint_fill: stroke.is_none() && overprint,
+ overprint_stroke: stroke.is_some() && overprint,
+ fill: if stroke.is_none() { paint } else { None },
+ stroke,
+ };
+ if authored {
+ return self.actual_type3_glyph(font_key, at, look, opacity, advance);
+ }
+ let text = visual_glyph_text(text);
+ let place = |line: &mut TextLine| line.push(&look, at, opacity, advance, &text);
+ self.flush_glyphs();
+ self.flush_type3();
+ if let (Some(s), Some(line)) = (&look.stroke, &mut self.text)
+ && look.fill.is_none()
+ && line.stroke_last(font_key, at, s.clone(), overprint)
+ {
+ return true;
+ }
+ if !self.text.as_mut().is_some_and(place) {
+ self.flush_text();
+ let mut line = TextLine::new(at, opacity);
+ place(&mut line);
+ self.text = Some(line);
+ }
+ if let Some(line) = &mut self.text {
+ line.has_type3 = true;
+ }
+ true
+ }
+
/// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps
/// its outline (no Unicode text, mirrored, not type mode, or in a span whose ActualText is
/// empty). No `paint`: invisible text,
diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs
index b39905d6e0..3fcefd7b8e 100644
--- a/crates/pdf/src/import/device.rs
+++ b/crates/pdf/src/import/device.rs
@@ -33,6 +33,7 @@ impl<'a> Device<'a> for Builder<'_> {
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'a>, draw_mode: &PathDrawMode) {
self.flush_glyphs();
+ self.flush_type3();
let walked = self.walked(false);
if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) {
return;
@@ -174,7 +175,7 @@ impl<'a> Device<'a> for Builder<'_> {
GlyphDrawMode::Invisible => {
let typed = match glyph {
Glyph::Outline(o) => self.type_glyph(o, transform * glyph_transform, mean_scale(transform), None, None, false),
- Glyph::Type3(_) => false,
+ Glyph::Type3(t3) => self.type_type3_glyph(t3, transform * glyph_transform, mean_scale(transform), None, None, false),
};
if !typed {
self.warn(INVISIBLE_TEXT_NOTE);
@@ -186,6 +187,7 @@ impl<'a> Device<'a> for Builder<'_> {
};
match glyph {
Glyph::Outline(o) => {
+ self.flush_type3();
let scale = mean_scale(transform);
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) {
@@ -216,16 +218,26 @@ impl<'a> Device<'a> for Builder<'_> {
if self.nested >= MAX_NESTED {
return;
}
- self.flush();
- self.nested += 1;
- t3.interpret(self, transform, glyph_transform, paint);
- self.nested -= 1;
+ let scale = mean_scale(transform);
+ let overprint = self.glyph_overprint(stroke.is_some());
+ if self.type_type3_glyph(t3, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) {
+ return;
+ }
+ self.flush_glyphs();
+ self.flush_text();
+ let art = self.nested(self.page, self.blend, |b| {
+ t3.interpret(b, transform, glyph_transform, paint);
+ });
+ if let Some(nodes) = art {
+ self.push_type3_nodes(nodes);
+ }
}
}
}
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
self.flush_glyphs();
+ self.flush_type3();
self.flush_text();
match image {
Image::Raster(r) => {
diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs
index b74cbf5dab..0470d641e2 100644
--- a/crates/pdf/src/import/state.rs
+++ b/crates/pdf/src/import/state.rs
@@ -62,6 +62,13 @@ pub(super) struct GlyphRun {
/// swatch made for it.
pub(super) type PatternReadings = (u32, Vec<(Vec, String)>);
+/// A run of Type 3 glyph graphics preserved inside a named group.
+#[derive(Clone, Debug)]
+pub(super) struct Type3Run {
+ pub(super) nodes: Vec>,
+ pub(super) group: Option,
+}
+
/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back.
pub(super) struct Saved {
pub(super) stack: Vec ,
@@ -69,6 +76,7 @@ pub(super) struct Saved {
pub(super) blend: BlendMode,
pub(super) mask: Option>,
pub(super) page: Rect,
+ pub(super) type3_run: Option,
}
pub(super) struct Builder<'p> {
@@ -80,6 +88,7 @@ pub(super) struct Builder<'p> {
pub(super) blend: BlendMode,
pub(super) page: Rect,
pub(super) glyphs: Option,
+ pub(super) type3_run: Option,
pub(super) text: Option,
/// The glyphs of the open line of type (`text`) that show, each as the outline it would be
/// kept as: what the line becomes when its type wouldn't draw as the file does
diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs
index 4f4e35dd3e..b65254eafa 100644
--- a/crates/pdf/src/import_scan.rs
+++ b/crates/pdf/src/import_scan.rs
@@ -13,6 +13,8 @@
use std::collections::{HashMap, HashSet};
+use siphasher::sip128::{Hasher128, SipHasher13};
+
use crate::import_color::items;
use craft_pdf::interpret::CacheKey;
use craft_pdf::syntax::Pdf;
@@ -250,6 +252,13 @@ fn pdf_doc_char(c: u8) -> char {
mapped.copied().unwrap_or(char::from(c))
}
+/// Calculate a 128-bit siphash of a value, matching hayro-interpret's `hash128`.
+pub(crate) fn hash128(value: &T) -> u128 {
+ let mut state = SipHasher13::new();
+ value.hash(&mut state);
+ state.finish128().as_u128()
+}
+
/// One page as the interpreter walks it.
#[derive(Default)]
pub(crate) struct Scan {
@@ -282,6 +291,8 @@ pub(crate) struct Scan {
/// fills and of its strokes (indices into [`Self::inks`]), where all of its text shown in an
/// ink is shown in the same one.
text_inks: Changes<[Option; 2]>,
+ /// Type 3 glyph cache key -> (font cache key, whether font has /ToUnicode).
+ pub type3_cache_map: HashMap,
}
impl Scan {
@@ -642,9 +653,58 @@ impl Walker<'_, '_> {
if let Some(f) = res.fonts.get::>(key.as_ref())
&& let Some(name) = font_name(&f)
{
- self.fonts.entry(f.cache_key()).or_insert_with(|| {
+ let is_type3 = f.get::>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3");
+ let to_unicode = f.get::>(b"ToUnicode").is_some();
+ let f_key = f.cache_key();
+ const MAX_TYPE3_CACHE_MAP: usize = 1 << 20;
+ if is_type3 && self.out.type3_cache_map.len().saturating_add(257) <= MAX_TYPE3_CACHE_MAP {
+ for gid in 0..=256u32 {
+ let hash = hash128(&(f_key, skrifa::GlyphId::new(gid)));
+ self.out.type3_cache_map.insert(hash, (f_key, to_unicode));
+ }
+ }
+ let is_colour = if is_type3 {
+ let mut colour = false;
+ let mut seen_streams = HashSet::new();
+ let mut decoded_bytes = 0usize;
+ const MAX_CHARPROC_BYTES: usize = 16 << 20;
+ if let Some(char_procs) = f.get::>(b"CharProcs") {
+ for key in char_procs.keys() {
+ if let Some(stream) = char_procs.get::>(key.as_ref()) {
+ if !seen_streams.insert(stream.cache_key()) {
+ continue;
+ }
+ if decoded_bytes >= MAX_CHARPROC_BYTES {
+ colour = true;
+ break;
+ }
+ if let Ok(data) = stream.decoded_within(MAX_CHARPROC_BYTES.saturating_sub(decoded_bytes)) {
+ decoded_bytes = decoded_bytes.saturating_add(data.len());
+ let mut iter = TypedIter::new(data.as_ref());
+ while let Some(op) = iter.next() {
+ match op {
+ TypedInstruction::ColorGlyph(_) => {
+ colour = true;
+ break;
+ }
+ TypedInstruction::ShapeGlyph(_) => break,
+ _ => {}
+ }
+ }
+ if colour {
+ break;
+ }
+ }
+ }
+ }
+ }
+ colour
+ } else {
+ false
+ };
+ self.fonts.entry(f_key).or_insert_with(|| {
let embedded = embeds_glyphs(&f);
- FontNote { name, embedded, vertical: vertical_metrics(&f, embedded) }
+ FontNote { name, embedded, vertical: vertical_metrics(&f, embedded), is_type3, to_unicode, is_colour }
});
}
}
@@ -879,6 +939,12 @@ pub(crate) struct FontNote {
pub embedded: bool,
/// A composite font written vertically: its descendant's metrics.
pub vertical: Option,
+ /// Whether this is a Type 3 font.
+ pub is_type3: bool,
+ /// Whether this font specifies a /ToUnicode map.
+ pub to_unicode: bool,
+ /// Whether this Type 3 font contains coloured glyph descriptions (d0/ColorGlyph).
+ pub is_colour: bool,
}
/// The most CIDs a vertical font's widths are read for (the most a CID-keyed font has).
@@ -1020,6 +1086,15 @@ fn cid_ranges(a: &Array<'_>, per: usize, mut each: impl FnMut(u32, &[f64])) {
/// descendant CIDFont's `BaseFont` is taken, the font program's own name (§9.7.4.1); without one,
/// the `BaseFont` without the CMap's name on its end.
fn font_name(f: &Dict<'_>) -> Option {
+ if f.get::>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") {
+ if let Some(base) = f.get::>(b"BaseFont") {
+ return Some(String::from_utf8_lossy(base.as_ref()).into_owned());
+ }
+ if let Some(name) = f.get::>(b"Name") {
+ return Some(String::from_utf8_lossy(name.as_ref()).into_owned());
+ }
+ return Some("Type3".to_string());
+ }
let base = String::from_utf8_lossy(f.get::>(b"BaseFont")?.as_ref()).into_owned();
if f.get::>(b"Subtype").is_none_or(|s| s.as_ref() != b"Type0") {
return Some(base);
diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs
index 200ec93673..9b0122326f 100644
--- a/crates/pdf/src/import_text.rs
+++ b/crates/pdf/src/import_text.rs
@@ -218,6 +218,8 @@ pub(crate) struct TextLine {
/// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far
/// (written right after the line).
pub held: Vec,
+ /// Whether this line holds Type 3 font glyphs.
+ pub has_type3: bool,
}
/// Where the file put a glyph: the glyph's index in the line, and its advance in the file's font
@@ -266,6 +268,7 @@ impl TextLine {
drawn_spaces: vec![],
drawn_space: None,
held: vec![],
+ has_type3: false,
}
}
diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs
index 8caac7b0de..8f9959c77f 100644
--- a/crates/pdf/src/lib.rs
+++ b/crates/pdf/src/lib.rs
@@ -5,7 +5,7 @@
//! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes
//! as their brush art), opacity and blend modes (transparency groups),
//! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and
-//! freeform and Perceptual gradients and dithered gradient fills as images at the raster
+//! freeform and Perceptual gradients, gradients mixed around the hue wheel and dithered gradient fills as images at the raster
//! effects resolution (clipped to what they paint), embedded images (resampled and
//! compressed as the Compression settings say) and text as outlined glyph paths, or as real
//! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and
diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs
index 5b86f2b895..45222bb04d 100644
--- a/crates/pdf/src/tests/cmykimages.rs
+++ b/crates/pdf/src/tests/cmykimages.rs
@@ -687,6 +687,26 @@ fn dithered_and_perceptual_gradients_of_cmyk_stops_are_written_as_cmyk_images()
assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB");
}
+/// The colors between two stops mixed around the hue wheel are not mixes of the stops' inks: a
+/// gradient of CMYK stops mixed that way is an RGB image, in a linear, radial, conic or diamond
+/// gradient.
+#[test]
+fn hue_wheel_gradients_of_cmyk_stops_are_written_as_rgb_images() {
+ for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] {
+ for mode in [ColorMode::Cmyk, ColorMode::Rgb] {
+ for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] {
+ let stops = vec![GradientStop::new(0.0, Color::cmyk(0.0, 1.0, 1.0, 0.0)), GradientStop::new(1.0, Color::cmyk(1.0, 1.0, 0.0, 0.0))];
+ let d = gradient_doc(mode, Gradient { method, ..Gradient::new(kind, stops) });
+ let out = export_with_report(&d, &opts(json!({}))).unwrap();
+ let w = written(&out.bytes);
+ assert_eq!(w.len(), 1, "{method:?} {mode:?} {kind:?}: one opaque image: {w:?}");
+ assert_eq!(w[0].space, "DeviceRGB", "{method:?} {mode:?} {kind:?}");
+ assert!(out.warnings.iter().any(|m| m.contains("exported as images")), "{method:?} {mode:?} {kind:?}: {:?}", out.warnings);
+ }
+ }
+ }
+}
+
/// A stop in another model, or linked to a spot swatch, keeps the RGB image.
#[test]
fn gradients_with_a_stop_that_is_not_process_cmyk_stay_rgb_images() {
diff --git a/crates/pdf/src/tests/text_placement.rs b/crates/pdf/src/tests/text_placement.rs
index f275def4dd..417a88e5b9 100644
--- a/crates/pdf/src/tests/text_placement.rs
+++ b/crates/pdf/src/tests/text_placement.rs
@@ -112,6 +112,25 @@ fn type3(first: usize, upm: f64) -> Vec {
vec![font, a, b]
}
+/// A Type 3 font with a ToUnicode CMap.
+fn type3_with_to_unicode(first: usize, upm: f64) -> Vec {
+ let s = 1.0 / upm;
+ let (h, w) = (upm * 0.5, upm);
+ let a = stream("", &format!("{h} 0 0 0 {h} {upm} d1 0 0 {} {} re f", h * 0.8, upm * 0.7));
+ let b = stream("", &format!("{w} 0 0 0 {w} {upm} d1 0 0 {} {} re f", w * 0.9, upm * 0.7));
+ let to_u = stream(
+ "",
+ "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /T3-UCS def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 2 beginbfchar\n<61> <0061>\n<62> <0062>\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
+ );
+ let font = format!(
+ "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 {w} {w}] /FontMatrix [{s} 0 0 {s} 0 0] /CharProcs << /a {} 0 R /b {} 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> /FirstChar 97 /LastChar 98 /Widths [{h} {w}] /ToUnicode {} 0 R /Resources << >> >>",
+ first + 1,
+ first + 2,
+ first + 3
+ );
+ vec![font, a, b, to_u]
+}
+
/// A 300 × 200 pt page drawing `content`, plus `entries` in the page dictionary, with fonts:
/// - `/F1`: Source Sans 3, embedded, a simple TrueType font;
/// - `/F4`: Source Sans 3 as a Type 0 font, Identity-H (two-byte glyph ids, see [`gids`]);
@@ -146,9 +165,13 @@ fn sized_page(w: f64, h: f64, content: &str, entries: &str) -> Vec {
let t3 = n + objs.len();
objs.extend(type3(t3, 1000.0));
objs.extend(type3(t3 + 3, 100.0));
+ let t3u = t3 + 6;
+ objs.extend(type3_with_to_unicode(t3u, 1000.0));
+ let t30u = t3u + 4;
+ objs.extend(type3_with_to_unicode(t30u, 100.0));
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let resources = format!(
- "/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>",
+ "/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /T3U {t3u} 0 R /T30U {t30u} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>",
n + 2,
n + 5,
n + 6,
@@ -416,7 +439,16 @@ fn vertical_glyphs_narrower_than_an_em_stay_on_their_column() {
fn type3_glyphs_follow_the_font_matrix() {
for font in ["T3", "T30"] {
let d = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text);
- assert!(texts(&d).is_empty(), "Type 3 text keeps its outlines");
+ assert!(texts(&d).is_empty(), "Type 3 text without ToUnicode keeps its outlines");
+ let mut groups = 0;
+ d.walk(|n| {
+ if let NodeKind::Group { .. } = &n.kind
+ && n.name.as_deref() == Some("")
+ {
+ groups += 1;
+ }
+ });
+ assert_eq!(groups, 1, "{font}: Type 3 text run must be preserved inside named group ");
let mut boxes = contours(&d);
boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0));
// `a` at x 20 (0.4 em = 8 pt wide), `b` after its half-em advance; 0.7 em = 14 pt tall,
@@ -429,6 +461,60 @@ fn type3_glyphs_follow_the_font_matrix() {
}
}
+/// Type 3 fonts with ToUnicode import as live text in Text mode, and as named groups in Paths mode.
+/// Tests both 1000 UPM (/T3U) and 100 UPM (/T30U) to verify FontMatrix scaling.
+#[test]
+fn type3_font_with_to_unicode_imports_as_live_text() {
+ for font in ["T3U", "T30U"] {
+ let d_text = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text);
+ let t = texts(&d_text);
+ assert_eq!(t.len(), 1, "{font}: Type 3 with ToUnicode should import as live text");
+ assert_eq!(t[0].plain_text(), "ab");
+ let run = &t[0].runs[0];
+ assert!((run.style.size - 20.0).abs() < 1e-3, "{font}: font size should be 20 pt, got {}", run.style.size);
+
+ let d_paths = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Outlines);
+ assert!(texts(&d_paths).is_empty(), "In Outlines mode, Type 3 text should be outlines");
+ let mut groups = 0;
+ d_paths.walk(|n| {
+ if let NodeKind::Group { .. } = &n.kind
+ && n.name.as_deref() == Some("")
+ {
+ groups += 1;
+ }
+ });
+ assert_eq!(groups, 1, "In Paths mode, Type 3 text should be grouped in ");
+ }
+}
+
+/// Invisible Type 3 text (mode 3) with ToUnicode imports as searchable text.
+#[test]
+fn type3_invisible_with_to_unicode_imports_as_searchable_text() {
+ let d = open_as(&page("BT /T3U 20 Tf 3 Tr 20 50 Td (ab) Tj ET", ""), TextAs::Text);
+ let t = texts(&d);
+ assert_eq!(t.len(), 1, "Invisible Type 3 text with ToUnicode imports as searchable text");
+ assert_eq!(t[0].plain_text(), "ab");
+ for r in &t[0].runs {
+ assert!(r.style.fill.is_none(), "Invisible text has no fill: {:?}", r.style.fill);
+ assert!(!r.style.has_stroke(), "Invisible text has no stroke");
+ }
+}
+
+/// Type 3 text with ActualText replacement honors authored text or suppression.
+#[test]
+fn type3_with_actual_text() {
+ // Authored replacement "XYZ" over Type 3 glyphs (ab)
+ let d = open_as(&page("/Span << /ActualText (XYZ) >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text);
+ let t = texts(&d);
+ assert_eq!(t.len(), 1, "Type 3 with ActualText should import as live text");
+ assert_eq!(t[0].plain_text(), "XYZ");
+
+ // Empty ActualText suppresses mapped text
+ let d_empty = open_as(&page("/Span << /ActualText () >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text);
+ let t_empty = texts(&d_empty);
+ assert!(t_empty.is_empty(), "Empty ActualText should suppress text");
+}
+
/// A font that isn't embedded or installed is set in a stand-in: the line still starts where
/// the file starts it, on its baseline, and is as long as the file's.
#[test]
diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs
index 29c502cbdd..bedf456f11 100644
--- a/crates/svg/src/export/paint.rs
+++ b/crates/svg/src/export/paint.rs
@@ -529,9 +529,9 @@ impl Writer<'_> {
self.warn(if freeform {
"freeform gradients are written as images clipped to their shapes"
} else if conic {
- "conic gradients with translucent stops, dither or the Perceptual method are written as images clipped to their shapes"
+ "conic gradients with translucent stops, dither or a method other than Classic are written as images clipped to their shapes"
} else if g.gradient.kind == GradientKind::Diamond {
- "diamond gradients with translucent stops, dither, the Perceptual method or no extent are written as images clipped to their shapes"
+ "diamond gradients with translucent stops, dither, a method other than Classic or no extent are written as images clipped to their shapes"
} else {
"linear and radial gradients drawn as pixels are written as images clipped to their shapes"
});
diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs
index b7013073d9..7e5d220fbb 100644
--- a/crates/svg/src/lib.rs
+++ b/crates/svg/src/lib.rs
@@ -47,6 +47,10 @@
//! rasterised in SVG
//! ().
//! On characters they are written as linear gradients through stops on the perceptual curve.
+//! * **Gradients mixed around the hue wheel** (Hue Clockwise and Hue Counterclockwise,
+//! VectorCraft's own methods) fill or stroke objects as an `` of the gradient too, at the
+//! document's raster effects resolution. On characters they are written as gradients through
+//! stops on their way around the wheel.
//! * **Symbol instances** the shared `` would paint differently from the canvas (stained,
//! scaled with strokes, turned or scaled with effects, brushes or live objects, or holding
//! pattern paints or unlinked masks, which stay on the page) are written as their own art.
diff --git a/crates/svg/tests/it/raster_paints.rs b/crates/svg/tests/it/raster_paints.rs
index 2ee1dd3bb5..a6bd69fc5a 100644
--- a/crates/svg/tests/it/raster_paints.rs
+++ b/crates/svg/tests/it/raster_paints.rs
@@ -214,6 +214,39 @@ fn perceptual_gradients_are_images_and_classic_ones_stay_vector() {
assert!(!svg.contains(" std::path::PathBuf {
dir
}
+/// A test's own temporary directory, removed with everything in it when dropped, also when the
+/// test panics. [`temp_dir`] makes it, with the same checks, under a tag no other `TempDir` in this
+/// process uses, so tests running alongside never share one. A removal that fails (a file still
+/// held open) is ignored; the next process with the same id empties the directory before use.
+pub struct TempDir(std::path::PathBuf);
+
+impl TempDir {
+ /// A new empty directory `vectorcraft-testkit---` in the system temporary
+ /// folder, where `n` counts the `TempDir`s made in this process.
+ pub fn new(tag: &str) -> Self {
+ static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
+ let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ Self(temp_dir(&format!("{tag}-{n}")))
+ }
+}
+
+impl std::ops::Deref for TempDir {
+ type Target = std::path::PathBuf;
+ fn deref(&self) -> &std::path::PathBuf {
+ &self.0
+ }
+}
+
+impl AsRef for TempDir {
+ fn as_ref(&self) -> &std::path::Path {
+ &self.0
+ }
+}
+
+impl Drop for TempDir {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+}
+
/// How many shapes an SVG draws: its `` elements and the basic shapes SVG export writes
/// simple shapes as (``, ``, ``, ``, ``, ``).
pub fn svg_shapes(svg: &str) -> usize {
@@ -134,4 +169,40 @@ mod tests {
assert!(super::temp_dir(tag).join("mine.txt").is_file(), "a later call keeps this process's files");
std::fs::remove_dir_all(&dir).unwrap();
}
+
+ #[test]
+ fn a_test_dir_is_removed_with_its_contents_when_dropped() {
+ let dir = super::TempDir::new("dropped");
+ let path = dir.to_path_buf();
+ std::fs::create_dir_all(path.join("nested")).unwrap();
+ std::fs::write(path.join("nested").join("a.txt"), b"a").unwrap();
+ drop(dir);
+ assert!(!path.exists(), "{} is left", path.display());
+ }
+
+ #[test]
+ fn a_test_dir_is_removed_when_its_test_panics() {
+ let mut path = None;
+ let r = super::catch_quiet::<()>(|| {
+ let dir = super::TempDir::new("panicked");
+ std::fs::write(dir.join("a.txt"), b"a").unwrap();
+ path = Some(dir.to_path_buf());
+ panic!("the test fails");
+ });
+ assert_eq!(r.err().as_deref(), Some("the test fails"));
+ let path = path.unwrap();
+ assert!(!path.exists(), "{} is left", path.display());
+ }
+
+ #[test]
+ fn test_dirs_with_one_tag_are_separate_and_private() {
+ let (a, b) = (super::TempDir::new("shared-tag"), super::TempDir::new("shared-tag"));
+ assert_ne!(*a, *b);
+ std::fs::write(a.join("a.txt"), b"a").unwrap();
+ assert_eq!(std::fs::read_dir(&*b).unwrap().count(), 0, "one test's files are not in another's directory");
+ #[cfg(unix)]
+ assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&*a).unwrap().permissions()) & 0o777, 0o700);
+ drop(a);
+ assert!(b.is_dir(), "dropping one leaves the other");
+ }
}
diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs
index 2b6cef4532..e2d6c9f77e 100644
--- a/crates/tools/src/builder/live_paint.rs
+++ b/crates/tools/src/builder/live_paint.rs
@@ -480,6 +480,8 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0);
/// Select Strokes off, Alt-click selects the face under the pointer as a click does and a marquee
/// takes no edges; with Select Fills off, a click selects the edge nearest the pointer as Alt-click
/// does and a marquee takes no faces; with both off, a click or a marquee selects no face or edge.
+/// The pointer shows what a click at that point selects ([`LivePaintSelectionTool::pick_at`]): a
+/// face, an edge, or nothing. From the press to the release it keeps the look it had at the press.
pub struct LivePaintSelectionTool {
pub(super) cache: RegionCache,
pub(super) hover: LpHover,
@@ -502,6 +504,8 @@ struct LpDrag {
shift: bool,
/// The face or edge under the press, which a click selects (or with Shift toggles).
hit: Option,
+ /// The cursor at the press, which the drag keeps.
+ cursor: Cursor,
}
impl LpDrag {
@@ -538,6 +542,22 @@ impl LivePaintSelectionTool {
(false, false) => None,
}
}
+
+ /// The face or edge a click at `p` selects, Alt held or not, with its cursor: the visible part of
+ /// the kind [`LivePaintSelectionTool::picks_edge`] asks for, of a Live Paint group the tool takes
+ /// ([`lp_part_at`], [`lp_selectable`]), and [`Cursor::LivePaintSelectFace`] or
+ /// [`Cursor::LivePaintSelectEdge`]; None and [`Cursor::LivePaintSelectOutside`] where a click
+ /// selects no face or edge.
+ fn pick_at(&self, cx: &ToolContext, p: Point, alt: bool) -> (Option, Cursor) {
+ let Some(edge) = self.picks_edge(alt) else { return (None, Cursor::LivePaintSelectOutside) };
+ let hit = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)).and_then(|(_, part)| part).map(|n| n.id);
+ let cursor = match (hit, edge) {
+ (None, _) => Cursor::LivePaintSelectOutside,
+ (Some(_), true) => Cursor::LivePaintSelectEdge,
+ (Some(_), false) => Cursor::LivePaintSelectFace,
+ };
+ (hit, cursor)
+ }
}
/// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and
@@ -592,9 +612,8 @@ impl Tool for LivePaintSelectionTool {
vec![]
}
PointerKind::Down => {
- let part = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e, &|id| lp_selectable(cx, id)));
- let hit = part.and_then(|(_, part)| part).map(|n| n.id);
- self.drag = Some(LpDrag { start: p, cur: p, shift: ev.mods.shift, hit });
+ let (hit, cursor) = self.pick_at(cx, p, ev.mods.alt);
+ self.drag = Some(LpDrag { start: p, cur: p, shift: ev.mods.shift, hit, cursor });
vec![]
}
PointerKind::Drag => {
@@ -652,8 +671,11 @@ impl Tool for LivePaintSelectionTool {
lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, self.width as f32)
}
- fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
- Cursor::Arrow
+ fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
+ match &self.drag {
+ Some(d) => d.cursor,
+ None => self.pick_at(cx, p, m.alt).1,
+ }
}
fn options(&self) -> Value {
diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs
index 89ce04eb3d..298e6a1907 100644
--- a/crates/tools/src/builder/tests.rs
+++ b/crates/tools/src/builder/tests.rs
@@ -1636,6 +1636,84 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights()
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
}
+/// The Live Paint Selection pointer shows what a click at the point selects, from the point and
+/// the modifier keys alone (no move first): a face, an edge, or nothing. Groups the tool does not
+/// take (locked, hidden, outside isolation) show nothing to select. A press keeps its look until
+/// the release.
+#[test]
+fn live_paint_selection_cursor_shows_what_a_click_selects() {
+ let mut d = Document::new(500.0, 500.0);
+ let (g, _) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
+ let (other, _) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0));
+ let s = Selection::default();
+ let p = paint();
+ let plain = Mods::default();
+ let alt = Mods { alt: true, ..Default::default() };
+ let (face, edge, out) = (Cursor::LivePaintSelectFace, Cursor::LivePaintSelectEdge, Cursor::LivePaintSelectOutside);
+ let at = |t: &LivePaintSelectionTool, cx: &ToolContext, x: f64, m: Mods| t.cursor(cx, Point::new(x, 50.0), m);
+ let mut t = LivePaintSelectionTool::default();
+ {
+ let cx = cx(&d, &s, &p);
+ // Both options on: a face inside the square; with Alt the side within reach, inside or
+ // outside the face; nothing away from the groups or from every side.
+ assert_eq!(
+ [at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, plain), at(&t, &cx, 102.0, plain), at(&t, &cx, 150.0, plain)],
+ [face, face, out, out]
+ );
+ assert_eq!([at(&t, &cx, 98.5, alt), at(&t, &cx, 102.0, alt), at(&t, &cx, 50.0, alt), at(&t, &cx, 150.0, alt)], [edge, edge, out, out]);
+ // The cursor agrees with what a click there selects.
+ let face_id = faces(d.node(g).unwrap())[0].id;
+ assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 50.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [face_id.0] }))]);
+ assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 50.0, 50.0).with_mods(alt)), vec![Action::Exec("select.none".into(), json!({}))]);
+ // Select Fills off: a side without Alt too, never a face.
+ t.set_option("selectFills", &json!(false));
+ assert_eq!([at(&t, &cx, 98.5, plain), at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, alt)], [edge, out, edge]);
+ // Select Strokes off: the face with Alt too, never a side.
+ t.set_option("selectFills", &json!(true));
+ t.set_option("selectStrokes", &json!(false));
+ assert_eq!([at(&t, &cx, 98.5, alt), at(&t, &cx, 102.0, alt)], [face, out]);
+ // Both off: nothing.
+ t.set_option("selectFills", &json!(false));
+ assert_eq!([at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, alt)], [out, out]);
+ }
+ let mut t = LivePaintSelectionTool::default();
+ // A locked group shows nothing to select; the other group still does.
+ d.node_mut(g).unwrap().locked = true;
+ {
+ let cx = cx(&d, &s, &p);
+ assert_eq!([at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, alt), at(&t, &cx, 250.0, plain)], [out, out, face]);
+ }
+ d.node_mut(g).unwrap().locked = false;
+ // In isolation mode, a group outside the isolated art shows nothing to select.
+ {
+ let cx = ToolContext { isolation: Some(other), ..cx(&d, &s, &p) };
+ assert_eq!([at(&t, &cx, 50.0, plain), at(&t, &cx, 250.0, plain)], [out, face]);
+ }
+ // A hidden group shows nothing to select.
+ d.node_mut(g).unwrap().visible = false;
+ {
+ let cx = cx(&d, &s, &p);
+ assert_eq!([at(&t, &cx, 50.0, plain), at(&t, &cx, 98.5, alt)], [out, out]);
+ }
+ d.node_mut(g).unwrap().visible = true;
+ // A press keeps its look while the drag goes elsewhere, and the release lets it follow again.
+ let cx = cx(&d, &s, &p);
+ t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
+ t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 50.0));
+ assert_eq!(at(&t, &cx, 150.0, plain), face);
+ t.pointer(&cx, &ev(PointerKind::Up, 150.0, 50.0));
+ assert_eq!(at(&t, &cx, 150.0, plain), out);
+ t.pointer(&cx, &ev(PointerKind::Down, 150.0, 50.0));
+ t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0));
+ assert_eq!(at(&t, &cx, 50.0, plain), out);
+ t.key(&cx, ToolKey::Escape, plain);
+ assert_eq!(at(&t, &cx, 50.0, plain), face, "Escape ends the press");
+ for c in [face, edge, out] {
+ assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
+ assert_eq!(c.stand_in(), None, "{c:?} is drawn");
+ }
+}
+
/// Presses at `from` with `m`, drags to `to` and releases there; returns what the release does
/// (the press and the drag do nothing).
fn lp_drag(t: &mut LivePaintSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), m: Mods) -> Vec {
diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs
index d5cdd05bc5..584149da8f 100644
--- a/crates/tools/src/cursor.rs
+++ b/crates/tools/src/cursor.rs
@@ -148,6 +148,14 @@ pub enum Cursor {
LivePaintBucketFace,
/// The Live Paint Bucket where a click paints an edge of a Live Paint group: a brush.
LivePaintBucketEdge,
+ /// Live Paint Selection over a face of a Live Paint group that a click selects: the arrow with a
+ /// filled square.
+ LivePaintSelectFace,
+ /// Live Paint Selection over an edge of a Live Paint group that a click selects: the arrow with
+ /// a short line.
+ LivePaintSelectEdge,
+ /// Live Paint Selection where a click selects no face or edge: the arrow with an x.
+ LivePaintSelectOutside,
/// The Measure tool.
Measure,
/// The Print Tiling tool.
@@ -248,7 +256,8 @@ impl Cursor {
}
/// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the
- /// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
+ /// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools', the Live
+ /// Paint tools') become a plain crosshair at the hotspot.
pub fn precise(self) -> Self {
match self {
Cursor::Pen
@@ -287,6 +296,9 @@ impl Cursor {
| Cursor::LivePaintBucket
| Cursor::LivePaintBucketFace
| Cursor::LivePaintBucketEdge
+ | Cursor::LivePaintSelectFace
+ | Cursor::LivePaintSelectEdge
+ | Cursor::LivePaintSelectOutside
| Cursor::Measure
| Cursor::PrintTiling
| Cursor::SymbolSprayer
diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml
index 107eb34aeb..6f5b32e56a 100644
--- a/crates/tools/src/cursors.toml
+++ b/crates/tools/src/cursors.toml
@@ -28,7 +28,7 @@ shown_when = [
"The Selection tool over empty canvas or over an unselected object (no Alt).",
"Dragging with the Selection tool (a move), with Direct Selection (an anchor, a segment or a handle), or with a shape tool (from the selection's centre point).",
"Any tool with no cursor of its own (Touch Type, Rotate View).",
- "The resting state of Live Paint Selection, Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.",
+ "The resting state of Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.",
]
hotspot = "The arrow's tip."
look = "The default pointer: a solid arrow. ArrowHollow is its hollow twin."
@@ -89,6 +89,39 @@ shown_when = [
hotspot = "The front arrowhead's tip."
look = "Says \"a copy\": a double arrowhead, one in front of the other."
+[[cursor]]
+id = "LivePaintSelectFace"
+group = "Selection"
+shown_when = [
+ "Live Paint Selection over a face of a Live Paint group that a click selects: inside the face, with Select Fills on, and without Alt unless Select Strokes is off.",
+ "From a press there until the release, wherever the drag goes.",
+ "With Use Precise Cursors on, the crosshair shows instead.",
+]
+hotspot = "The arrow's tip."
+look = "Arrow with a small filled square badge (a face). LivePaintSelectEdge and LivePaintSelectOutside share the arrow."
+
+[[cursor]]
+id = "LivePaintSelectEdge"
+group = "Selection"
+shown_when = [
+ "Live Paint Selection within reach of an edge of a Live Paint group that a click selects: Select Strokes is on, and Alt is held or Select Fills is off.",
+ "From a press there until the release, wherever the drag goes.",
+ "With Use Precise Cursors on, the crosshair shows instead.",
+]
+hotspot = "The arrow's tip."
+look = "Arrow with a short line badge (an edge)."
+
+[[cursor]]
+id = "LivePaintSelectOutside"
+group = "Selection"
+shown_when = [
+ "Live Paint Selection where a click selects no face or edge: away from Live Paint groups, over a group that is locked, hidden or outside the isolated art, inside a face with Alt held and no edge within reach, or with Select Fills and Select Strokes both off.",
+ "From a press there until the release, wherever the drag goes.",
+ "With Use Precise Cursors on, the crosshair shows instead.",
+]
+hotspot = "The arrow's tip."
+look = "Arrow with a small x badge (nothing to select)."
+
[[cursor]]
id = "Move"
group = "Selection"
diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs
index 095e0d21cf..c7a205a19e 100644
--- a/crates/tools/src/params.rs
+++ b/crates/tools/src/params.rs
@@ -76,7 +76,7 @@ pub fn gradient_params(g: &GradientPaint) -> Value {
v["dither"] = json!(true);
}
if !g.gradient.method.is_classic() {
- v["method"] = json!(g.gradient.method.label().to_lowercase());
+ v["method"] = json!(g.gradient.method.id());
}
// A freeform gradient has no ends to spread past (`paint.setFill` refuses a spread for one).
if g.gradient.is_spread() {
diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs
index 2d137153fc..1583584940 100644
--- a/crates/tools/src/xform/free.rs
+++ b/crates/tools/src/xform/free.rs
@@ -4,8 +4,9 @@
//! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle
//! shears along that side. A scale snaps as the Selection tool's does. The Perspective Distort and Free Distort modes (tool option `mode`, or
//! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the
-//! box and preview `object.distort`. After a rotation the box stays turned with the selection, as
-//! the Selection tool's does: its handles scale, shear and distort along the selection's own sides.
+//! box and preview `object.distort`; in Free Distort, Shift or the Constrain option keeps a dragged
+//! corner on the diagonal through the opposite corner. After a rotation the box stays turned with
+//! the selection, as the Selection tool's does: its handles scale, shear and distort along the selection's own sides.
use serde_json::{Value, json};
use vectorcraft_doc::OrientedBox;
@@ -123,6 +124,18 @@ fn corner_index(h: Handle) -> Option {
}
}
+/// The part of drag `d` that lies along the diagonal from corner `h` of `r` to the opposite
+/// corner. A Free Distort corner dragged with Shift or Constrain moves only along that line, so its
+/// offset from the opposite corner keeps the box's width-to-height ratio. A box with no width and no height gives no movement.
+pub fn along_diagonal(r: Rect, h: Handle, d: Vec2) -> Vec2 {
+ let u = h.pos(r) - h.opposite().pos(r);
+ let len2 = u.hypot2();
+ if len2 <= 1e-18 || !len2.is_finite() {
+ return Vec2::ZERO;
+ }
+ u * (d.dot(u) / len2)
+}
+
/// The distorted quad for dragging handle `h` of `r` by `d`.
pub fn distort_quad(r: Rect, h: Handle, d: Vec2, mode: DistortMode) -> [Point; 4] {
let mut q = rect_corners(r);
@@ -194,9 +207,13 @@ impl FreeTransformTool {
self.guides.clear();
let xf = match d.op {
Op::Corners(h, mode) => {
+ // Shift or Constrain keeps a Free Distort corner on its diagonal.
+ let constrained = shift && mode == DistortMode::Distort && h.is_corner();
+ let local = if constrained { along_diagonal(bx.rect, h, local) } else { local };
let q = distort_quad(bx.rect, h, local, mode).map(|c| bx.to_doc() * c);
self.quad = Some(q);
- self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(delta.x, delta.y)));
+ let moved = if constrained { bx.to_doc() * (h.pos(bx.rect) + local) - bx.to_doc() * h.pos(bx.rect) } else { delta };
+ self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(moved.x, moved.y)));
let corners: Vec = q.iter().map(|c| json!([c.x, c.y])).collect();
let r = bx.rect;
let mut params = json!({ "corners": corners, "from": [r.x0, r.y0, r.x1, r.y1] });
@@ -571,6 +588,123 @@ mod tests {
assert_eq!(q[2], Point::new(10.0, 10.0));
}
+ /// The corners an `object.distort` preview sends.
+ fn sent_corners(a: &[Action]) -> Vec {
+ let Some(Action::Preview(c, v)) = a.last() else { panic!("{a:?}") };
+ assert_eq!(c, "object.distort");
+ v["corners"].as_array().unwrap().iter().map(|p| Point::new(p[0].as_f64().unwrap(), p[1].as_f64().unwrap())).collect()
+ }
+
+ fn assert_corners(got: &[Point], want: [(f64, f64); 4]) {
+ for (g, w) in got.iter().zip(want) {
+ assert!(g.distance(Point::new(w.0, w.1)) < 1e-9, "{got:?} != {want:?}");
+ }
+ }
+
+ /// A 200 × 100 rectangle at (100, 100)–(300, 200), selected.
+ fn wide_rect() -> (vectorcraft_doc::Document, Selection, vectorcraft_doc::NodeId) {
+ let (mut d, id) = doc_with_rect();
+ let wide = Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(2.0, 1.0) * Affine::translate((-100.0, -100.0));
+ d.node_mut(id).unwrap().transform(wide, false);
+ let mut s = Selection::default();
+ s.add(id);
+ (d, s, id)
+ }
+
+ /// In Free Distort, Shift or the Constrain option keeps the dragged corner on the diagonal
+ /// through the opposite corner; the other three corners stay put.
+ #[test]
+ fn free_distort_corner_keeps_to_its_diagonal_with_shift_or_constrain() {
+ let (d, s, _) = wide_rect();
+ let p = paint();
+ let cx = cx(&d, &s, &p);
+ let shift = Mods { shift: true, ..Default::default() };
+ // The bottom-right corner dragged by (40, 60): along the diagonal (200, 100) that is
+ // (40·200 + 60·100) / (200² + 100²) = 0.28 of it, (56, 28).
+ let fixed = [(100.0, 100.0), (300.0, 100.0), (356.0, 228.0), (100.0, 200.0)];
+ let drag = |t: &mut FreeTransformTool, m: Mods| {
+ t.pointer(&cx, &ev(PointerKind::Down, 300.0, 200.0).with_mods(m));
+ let a = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 260.0).with_mods(m));
+ t.pointer(&cx, &ev(PointerKind::Up, 340.0, 260.0).with_mods(m));
+ sent_corners(&a)
+ };
+ let mut t = FreeTransformTool::default();
+ t.set_option("mode", &json!("distort"));
+ assert_corners(&drag(&mut t, Mods::default()), [(100.0, 100.0), (300.0, 100.0), (340.0, 260.0), (100.0, 200.0)]);
+ assert_corners(&drag(&mut t, shift), fixed);
+ t.set_option("constrain", &json!(true));
+ assert_corners(&drag(&mut t, Mods::default()), fixed);
+ // Cmd+Shift on a corner in the Free Transform mode is Free Distort, constrained too.
+ let mut t = FreeTransformTool::default();
+ assert_corners(&drag(&mut t, Mods { cmd: true, shift: true, ..Default::default() }), fixed);
+ // Cmd+Alt+Shift stays Perspective Distort, with Constrain on as well.
+ t.set_option("constrain", &json!(true));
+ let perspective = Mods { cmd: true, alt: true, shift: true, ..Default::default() };
+ assert_corners(&drag(&mut t, perspective), [(100.0, 100.0), (300.0, 40.0), (300.0, 260.0), (100.0, 200.0)]);
+ // The measurement label shows how far the corner moved.
+ let cx = ToolContext { measurement_labels: true, ..cx };
+ let mut t = FreeTransformTool::default();
+ t.set_option("mode", &json!("distort"));
+ t.pointer(&cx, &ev(PointerKind::Down, 300.0, 200.0).with_mods(shift));
+ t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 260.0).with_mods(shift));
+ let want = cx.offset_label(56.0, 28.0);
+ assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want)), "{want}");
+ }
+
+ /// Shift pressed in the middle of a Free Distort corner drag constrains it from then on, in the
+ /// same undo step; letting go frees the corner again.
+ #[test]
+ fn shift_pressed_during_a_free_distort_drag_constrains_it() {
+ let (d, s, _) = wide_rect();
+ let p = paint();
+ let cx = cx(&d, &s, &p);
+ let mut t = FreeTransformTool::default();
+ t.set_option("mode", &json!("distort"));
+ t.pointer(&cx, &ev(PointerKind::Down, 300.0, 200.0));
+ let a = t.pointer(&cx, &ev(PointerKind::Drag, 310.0, 210.0));
+ assert_eq!(a[0], Action::Begin("Distort".into()));
+ let a = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 260.0).with_mods(Mods { shift: true, ..Default::default() }));
+ assert_eq!(a.len(), 1, "no new undo step: {a:?}");
+ assert_corners(&sent_corners(&a), [(100.0, 100.0), (300.0, 100.0), (356.0, 228.0), (100.0, 200.0)]);
+ let a = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 260.0));
+ assert_corners(&sent_corners(&a), [(100.0, 100.0), (300.0, 100.0), (340.0, 260.0), (100.0, 200.0)]);
+ assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 340.0, 260.0)), vec![Action::Commit]);
+ }
+
+ /// A turned box is constrained along its own diagonal.
+ #[test]
+ fn a_turned_box_is_constrained_along_its_own_diagonal() {
+ let (mut d, s, id) = wide_rect();
+ let c = Vec2::new(200.0, 150.0);
+ let turn = Affine::translate(c) * Affine::rotate(-30f64.to_radians()) * Affine::translate(-c);
+ d.node_mut(id).unwrap().transform(turn, false);
+ let p = paint();
+ let cx = cx(&d, &s, &p);
+ let bx = selection_box(&cx).unwrap();
+ assert!((bx.rect.width() - 200.0).abs() < 1e-9 && (bx.rect.height() - 100.0).abs() < 1e-9, "{bx:?}");
+ let corner = Handle::BottomRight.pos(bx.rect);
+ let (from, to) = (bx.to_doc() * corner, bx.to_doc() * (corner + Vec2::new(40.0, 60.0)));
+ let mut t = FreeTransformTool::default();
+ t.set_option("mode", &json!("distort"));
+ t.set_option("constrain", &json!(true));
+ t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y));
+ let got = sent_corners(&t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y)));
+ let want = bx.to_doc() * (corner + Vec2::new(56.0, 28.0));
+ assert!(got[2].distance(want) < 1e-9, "{got:?} {want:?}");
+ for (i, k) in [(0, Handle::TopLeft), (1, Handle::TopRight), (3, Handle::BottomLeft)] {
+ assert!(got[i].distance(bx.to_doc() * k.pos(bx.rect)) < 1e-9, "{got:?}");
+ }
+ }
+
+ /// A box with no size has no diagonal to move along; a line keeps to its own length.
+ #[test]
+ fn along_diagonal_of_a_degenerate_box() {
+ assert_eq!(along_diagonal(Rect::new(5.0, 5.0, 5.0, 5.0), Handle::BottomRight, Vec2::new(3.0, 4.0)), Vec2::ZERO);
+ assert_eq!(along_diagonal(Rect::new(5.0, 0.0, 5.0, 10.0), Handle::BottomRight, Vec2::new(3.0, 4.0)), Vec2::new(0.0, 4.0));
+ let d = along_diagonal(Rect::new(0.0, 0.0, 10.0, 10.0), Handle::TopLeft, Vec2::new(1.0, 0.0));
+ assert!(d.x.is_finite() && (d - Vec2::new(0.5, 0.5)).hypot() < 1e-12, "{d:?}");
+ }
+
#[test]
fn distort_drag_shows_move_measurement_labels() {
let (d, id) = doc_with_rect();
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index d75eae537f..d8b415f922 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -67,8 +67,10 @@ pub(crate) use panes::{consolidate, pane, tile, tiled_area};
use raster::{changed_pixels, pixel_region, upload};
pub(crate) use raster::{color_image, color_image_owned, render_job};
pub use selection_marks::SelectionMarks;
+#[cfg(test)]
+pub(crate) use task_bar::chip_paints;
use task_bar::task_bar;
-pub(crate) use task_bar::{has_colors, task_bar_rect};
+pub(crate) use task_bar::{has_colors, task_bar_rect, task_bar_shows_fonts, task_bar_shows_type};
#[cfg(test)]
use tool_overlays::{DIAL_RADIUS, HiddenMarks};
pub use tool_overlays::{HiddenChars, SpellingMarks};
diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs
index 8b368fa496..ca017bef3e 100644
--- a/crates/ui-egui/src/canvas/cursor.rs
+++ b/crates/ui-egui/src/canvas/cursor.rs
@@ -83,7 +83,10 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
| Cursor::CornerRadius
| Cursor::PathBracket
| Cursor::TypeWidget
- | Cursor::LoadedText => C::Default,
+ | Cursor::LoadedText
+ | Cursor::LivePaintSelectFace
+ | Cursor::LivePaintSelectEdge
+ | Cursor::LivePaintSelectOutside => C::Default,
Cursor::SelectionCopy => C::Copy,
Cursor::Move => C::Move,
Cursor::Crosshair => C::Crosshair,
diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs
index 5372cc083d..e908640c25 100644
--- a/crates/ui-egui/src/canvas/task_bar.rs
+++ b/crates/ui-egui/src/canvas/task_bar.rs
@@ -13,6 +13,12 @@ pub(crate) fn task_bar_rect(ctx: &egui::Context) -> Option {
ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten())
}
+/// Whether the Contextual Task Bar, where it was last drawn, held the font controls: on a canvas
+/// too narrow for them it leaves them out.
+pub(crate) fn task_bar_shows_fonts(ctx: &egui::Context) -> bool {
+ task_bar_rect(ctx).is_some() && ctx.data(|d| d.get_temp::(task_bar_id().with("fonts")).unwrap_or(false))
+}
+
/// Whether the objects `ids` (or anything inside them) are painted with a colour: a solid colour,
/// a gradient's stops or a mesh's points, which Recolor Artwork can change.
pub(crate) fn has_colors(doc: &vectorcraft_doc::Document, ids: &[vectorcraft_doc::NodeId]) -> bool {
@@ -30,21 +36,59 @@ fn picked_artboards_bounds(app: &VectorcraftApp) -> Option {
picked.iter().filter_map(|i| st.doc.artboards.get(*i)).map(|a| a.rect).reduce(|a, b| a.union(b))
}
+/// The Type tools whose Contextual Task Bar shows the text formatting controls. The Touch Type
+/// tool has no bar: its own handles take the place of one.
+const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"];
+
+/// Whether every selected object is a type object, with no anchors picked out.
+fn only_type_selected(app: &VectorcraftApp) -> bool {
+ app.session.active().is_some_and(|st| {
+ !st.selection.is_empty()
+ && st.selection.anchors.is_empty()
+ && st.selection.objects.iter().all(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))))
+ })
+}
+
+/// Whether a selection tool is in use with something selected.
+fn selection_tool_with_selection(app: &VectorcraftApp) -> bool {
+ matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")
+ && app.session.active().is_some_and(|st| !st.selection.is_empty())
+}
+
+/// Whether a Type tool edits type, or has type selected: its bar then shows only the text
+/// formatting controls.
+fn type_tool_on_type(app: &VectorcraftApp) -> bool {
+ TYPE_TOOLS.contains(&app.session.tool_id()) && (crate::panels::character::text_editing(app).is_some() || only_type_selected(app))
+}
+
+/// Whether the Contextual Task Bar shows the font family, font style and Font Size controls: a
+/// Type tool edits or has selected type, or a selection tool has only type selected.
+pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool {
+ type_tool_on_type(app) || (selection_tool_with_selection(app) && only_type_selected(app))
+}
+
/// The Contextual Task Bar: a floating pill under the selection with the most likely next actions.
/// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard
/// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens).
/// A basic shape selected with the Selection tool shows a Shape Properties button in place of Lock.
+/// Selected type also shows the font family, font style and Font Size controls before its Fill.
+/// While a Type tool edits type, or has type selected, the bar shows only those controls and the
+/// Fill. The font controls take a second row when one row would be wider than the canvas, and
+/// narrow there to fit it.
/// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the
/// selection, pinned it stays put ([`crate::state::TaskBarPlace`]).
pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let t = Tokens::get(ui.ctx());
let on_artboards = app.session.tool_id() == "artboard";
- if !on_artboards
- && (app.session.active().is_none_or(|st| st.selection.is_empty())
- || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection"))
- {
+ // Text added with a Type tool shows its formatting controls, as the help page describes
+ // ().
+ let typing = type_tool_on_type(app);
+ if !(on_artboards || typing || selection_tool_with_selection(app)) {
return;
}
+ // Selected type has the font controls too, as the help page describes
+ // ().
+ let fonts = task_bar_shows_type(app);
let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return };
let Some(st) = app.session.active() else { return };
let n = st.selection.len();
@@ -93,7 +137,9 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none()));
}
items.push((tl!("Export"), "share-2", "file.exportForScreens", none()));
- } else {
+ } else if !typing {
+ // The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines
+ // and the type conversions included.
// Edit Path: the Direct Selection tool, to edit the path's anchors.
let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"}));
// As in the reference app, an image offers Image Trace, Crop Image and Remove Background, and
@@ -185,22 +231,31 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
}
items.push((tl!("Duplicate"), "copy", "edit.duplicate", none()));
}
- // The paints the Fill and Stroke proxies show, "?" when the selected objects' paints differ.
- let (fill, stroke) = crate::panels::current_paints(app);
- let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ui.ctx());
+ let (fill, stroke, fill_mixed, stroke_mixed) = chip_paints(app, ui.ctx());
let colored = has_colors(&st.doc, &st.selection.objects);
let doc = st.uid;
// Under the selection's lowest corner on screen, centered on the selection (the view may be
// rotated).
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
let anchor = pos2(xf.to_screen(b.center()).x, lowest);
- let est_w = 150.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::();
+ let bounds = xf.rect.shrink(8.0);
+ // On a canvas too narrow for the type controls at their narrowest, the bar leaves them out.
+ let fonts = fonts && bounds.width() - 18.0 >= crate::panels::character::task_bar_min_room();
+ ui.ctx().data_mut(|d| d.insert_temp(task_bar_id().with("fonts"), fonts));
+ // The width of the bar's frame (margins and stroke), its handle, Fill and Stroke chips,
+ // Recolor Artwork, Lock or Shape Properties, and More Options, each after a 6-point gap.
+ let chips: f32 =
+ [(!on_artboards, 26.0), (!on_artboards && !typing, 26.0), (!on_artboards && !typing && colored, 30.0), (!on_artboards && !typing, 30.0)]
+ .iter()
+ .filter(|(shown, _)| *shown)
+ .map(|(_, w)| w + 6.0)
+ .sum();
+ let (font_row, est) = size_estimate(&items, fonts, 18.0 + 4.0 + chips + 36.0, bounds.width());
// Its place under the selection, then where the handle moved it, kept on the canvas (with the
// bar's size last frame, the estimate before it first shows).
- let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0);
+ let under = pos2(anchor.x - est.x / 2.0, anchor.y + 28.0);
let id = task_bar_id();
- let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(vec2(est_w, 44.0));
- let bounds = xf.rect.shrink(8.0);
+ let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(est);
let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min));
let place = &mut app.ui.task_bar_place;
// Just unpinned: follow the selection from the pinned spot.
@@ -255,23 +310,21 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
run = Some((*cmd, params.clone()));
}
}
+ // Type's font family, font style and Font Size, which act as the Character
+ // panel's do: on the characters a Type tool selects, else on the selected type.
+ if fonts && !font_row {
+ crate::panels::character::task_bar(app, ui, f32::INFINITY);
+ }
// The selected objects' Fill, Stroke, Recolor Artwork and Lock (a basic shape's
- // properties instead), left out with the Artboard tool.
- if !on_artboards {
- let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click());
- let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0));
- widgets::proxy_chip(ui, chip, &fill, fill_mixed, None);
- ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside);
- if resp.on_hover_text(tl!("Fill")).clicked() {
- app.session.fill_active = true;
- show_swatches = true;
- }
+ // properties instead), left out with the Artboard tool. A Type tool's bar has
+ // the Fill alone.
+ if !on_artboards && paint_chip(ui, &t, &fill, fill_mixed, None, tl!("Fill")) {
+ app.session.fill_active = true;
+ show_swatches = true;
+ }
+ if !on_artboards && !typing {
// The Stroke chip, drawn as a ring as the Control bar's is.
- let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click());
- let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0));
- widgets::proxy_chip(ui, chip, &stroke, stroke_mixed, Some(4.0));
- ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside);
- if resp.on_hover_text(tl!("Stroke")).clicked() {
+ if paint_chip(ui, &t, &stroke, stroke_mixed, Some(4.0), tl!("Stroke")) {
app.session.fill_active = false;
show_swatches = true;
}
@@ -316,6 +369,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
}
});
});
+ if font_row {
+ ui.horizontal(|ui| {
+ ui.spacing_mut().item_spacing.x = 6.0;
+ // The canvas's width less the frame's margins and stroke.
+ crate::panels::character::task_bar(app, ui, bounds.width() - 18.0);
+ });
+ }
});
});
if moved != Vec2::ZERO {
@@ -353,6 +413,42 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
}
}
+/// Whether the type controls go on a second row, and the bar's estimated size before it first
+/// shows: `items` and, when `fonts`, the font family, font style and Font Size controls, which take
+/// a second row when one row, with the bar's other controls `fixed` points wide, would be wider than
+/// `room` (and narrow to fit it there).
+fn size_estimate(items: &[(&str, &str, &str, serde_json::Value)], fonts: bool, fixed: f32, room: f32) -> (bool, Vec2) {
+ let font_w: f32 = crate::panels::character::TASK_BAR_WIDTHS.iter().filter(|_| fonts).map(|w| w + 6.0).sum();
+ let items_w = if fonts { fixed } else { 150.0 } + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::();
+ if fonts && items_w + font_w > room { (true, vec2(items_w.max((font_w + 16.0).min(room)), 80.0)) } else { (false, vec2(items_w + font_w, 44.0)) }
+}
+
+/// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or
+/// while a Type tool edits type, the edited characters' fill, which a swatch picked then paints.
+pub(crate) fn chip_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool) {
+ let (fill, stroke) = crate::panels::current_paints(app);
+ let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ctx);
+ if crate::panels::character::text_editing(app).is_some()
+ && let Some((chars, _)) = crate::panels::character::text_style(app)
+ {
+ let mixed = crate::panels::character::shared(app, |s| s.fill.clone()).is_none();
+ return (chars.fill, stroke, mixed, stroke_mixed);
+ }
+ (fill, stroke, fill_mixed, stroke_mixed)
+}
+
+/// One of the Contextual Task Bar's Fill and Stroke chips, showing `paint` ("?" when `mixed`, a
+/// ring of `ring` points for the stroke), named `tip` for screen readers. Returns whether it was
+/// clicked.
+fn paint_chip(ui: &mut Ui, t: &Tokens, paint: &vectorcraft_color::Paint, mixed: bool, ring: Option, tip: &str) -> bool {
+ let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click());
+ let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0));
+ widgets::proxy_chip(ui, chip, paint, mixed, ring);
+ ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside);
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip));
+ resp.on_hover_text(tip).clicked()
+}
+
/// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a
/// closed path.
fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) {
diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs
index 34156f2c41..a438a71881 100644
--- a/crates/ui-egui/src/cursors.rs
+++ b/crates/ui-egui/src/cursors.rs
@@ -468,6 +468,27 @@ fn paint_brush(p: &mut Ink, o: Pos2) {
}
}
+/// Live Paint Selection: the solid arrow with a badge at its lower right saying what a click
+/// selects: a filled square over a face ([`Cursor::LivePaintSelectFace`]), a short line between two
+/// anchor marks over an edge ([`Cursor::LivePaintSelectEdge`]), an x where it selects nothing.
+fn live_paint_select(p: &mut Ink, o: Pos2, part: Cursor) {
+ arrow(p, o, false);
+ let b = o + vec2(12.0, 13.0);
+ match part {
+ Cursor::LivePaintSelectFace => {
+ poly(p, [(0.0, 0.0), (6.0, 0.0), (6.0, 6.0), (0.0, 6.0)].map(|(x, y)| b + vec2(x, y)).to_vec(), INK, INK);
+ }
+ Cursor::LivePaintSelectEdge => {
+ let (a, c) = (b + vec2(0.0, 7.0), b + vec2(7.0, 0.0));
+ strokes(p, &[vec![a, c]]);
+ for m in [a, c] {
+ poly(p, [(-1.5, -1.5), (1.5, -1.5), (1.5, 1.5), (-1.5, 1.5)].map(|(x, y)| m + vec2(x, y)).to_vec(), INK, INK);
+ }
+ }
+ _ => strokes(p, &[vec![b, b + vec2(6.0, 6.0)], vec![b + vec2(6.0, 0.0), b + vec2(0.0, 6.0)]]),
+ }
+}
+
/// The Hand tool and Space: an open hand (palm and four fingers, thumb to the left), or the same
/// hand closed while it drags the view. Windows has no system hand that isn't a pointing finger
/// (winit maps egui's Grab and Grabbing to the four-headed move cursor), so it is drawn like the
@@ -516,7 +537,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
#[cfg(test)]
-const GLYPHS: [Cursor; 95] = [
+const GLYPHS: [Cursor; 98] = [
Cursor::Hand,
Cursor::HandGrab,
Cursor::ZoomIn,
@@ -586,6 +607,9 @@ const GLYPHS: [Cursor; 95] = [
Cursor::LivePaintBucket,
Cursor::LivePaintBucketFace,
Cursor::LivePaintBucketEdge,
+ Cursor::LivePaintSelectFace,
+ Cursor::LivePaintSelectEdge,
+ Cursor::LivePaintSelectOutside,
Cursor::Measure,
Cursor::PrintTiling,
Cursor::SymbolSprayer,
@@ -710,6 +734,7 @@ fn glyph(c: Cursor, p: Pos2) -> Option> {
Cursor::LivePaintBucket => paint_bucket(ink, p, false),
Cursor::LivePaintBucketFace => paint_bucket(ink, p, true),
Cursor::LivePaintBucketEdge => paint_brush(ink, p),
+ Cursor::LivePaintSelectFace | Cursor::LivePaintSelectEdge | Cursor::LivePaintSelectOutside => live_paint_select(ink, p, c),
Cursor::Hand => hand(ink, p, false),
Cursor::HandGrab => hand(ink, p, true),
Cursor::ZoomIn => magnifier(ink, p, false),
diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs
index 4a12ff91b6..ae1947616f 100644
--- a/crates/ui-egui/src/cursors/tests.rs
+++ b/crates/ui-egui/src/cursors/tests.rs
@@ -331,6 +331,45 @@ fn the_live_paint_bucket_shows_a_bucket_or_a_brush() {
}
}
+/// Live Paint Selection's cursors are the solid arrow, its tip on the hotspot, with a badge at its
+/// lower right: a filled square over a face, a line between two anchor marks over an edge, and an x
+/// where a click selects nothing.
+#[test]
+fn live_paint_selection_shows_the_arrow_with_a_face_edge_or_x_badge() {
+ for ppp in [2.0f32, 3.0] {
+ let img = |c| rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}"));
+ let (arrow, cross) = (img(Cursor::Arrow), img(Cursor::Crosshair));
+ let (face, edge, out) = (img(Cursor::LivePaintSelectFace), img(Cursor::LivePaintSelectEdge), img(Cursor::LivePaintSelectOutside));
+ // The pixel at (dx, dy) points from the hotspot.
+ let at = |img: &CustomCursorImage, dx: f32, dy: f32| {
+ let [hx, hy] = img.hotspot.map(usize::from);
+ let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize);
+ if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] }
+ };
+ let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64;
+ // Inside the square, away from the x's strokes and the edge's line.
+ let square = (15.0, 14.0);
+ // The edge's upper anchor mark, beyond the square and the x.
+ let mark = (19.0, 13.0);
+ // On the x's stroke from top left to bottom right, away from the edge's line.
+ let x_stroke = (13.0, 14.0);
+ for (c, i, want) in [
+ (Cursor::LivePaintSelectFace, &face, [true, false, true]),
+ (Cursor::LivePaintSelectEdge, &edge, [false, true, false]),
+ (Cursor::LivePaintSelectOutside, &out, [false, false, true]),
+ ] {
+ assert_eq!(i.hotspot, arrow.hotspot, "{c:?} has the arrow's hotspot at {ppp}");
+ assert!(inked(at(i, 1.5, 6.0)), "{c:?} is the solid arrow at {ppp}");
+ let got = [square, mark, x_stroke].map(|(x, y)| inked(at(i, x, y)));
+ assert_eq!(got, want, "{c:?} at {ppp}");
+ assert!(c.stand_in().is_none(), "{c:?} is drawn");
+ assert_ne!(i, &arrow, "{c:?} is not the plain arrow at {ppp}");
+ assert_eq!(rasterize(c.precise(), ppp).as_ref(), Some(&cross), "{c:?} with Use Precise Cursors at {ppp}");
+ }
+ assert!(face != edge && edge != out && face != out, "three looks at {ppp}");
+ }
+}
+
#[test]
fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() {
let mut images = Images::default();
diff --git a/crates/ui-egui/src/dialogs/color_picker/tests.rs b/crates/ui-egui/src/dialogs/color_picker/tests.rs
index 4d678b1069..5587b9c560 100644
--- a/crates/ui-egui/src/dialogs/color_picker/tests.rs
+++ b/crates/ui-egui/src/dialogs/color_picker/tests.rs
@@ -106,6 +106,94 @@ fn gamut_correction_gives_a_printable_colour() {
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None);
}
+/// The cached warning follows the CMYK space it is checked in: assigning the document a CMYK
+/// profile, removing it, or changing the working CMYK space updates it for the same color.
+#[test]
+fn gamut_warning_follows_a_profile_change() {
+ const CHILD: &str = "VECTORCRAFT_TEST_GAMUT_PROFILE_CHANGE";
+ if std::env::var_os(CHILD).is_none() {
+ // Color settings are process-wide: change them in a separate test process.
+ let output = std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", "dialogs::color_picker::tests::gamut_warning_follows_a_profile_change", "--nocapture"])
+ .env(CHILD, "1")
+ .output()
+ .unwrap();
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
+ "isolated gamut test failed:\n{stdout}\n{}",
+ String::from_utf8_lossy(&output.stderr),
+ );
+ return;
+ }
+ use vectorcraft_color::cms;
+ let mut app = app();
+ let ctx = egui::Context::default();
+ let blue = Color::rgb(0.0, 0.0, 1.0);
+ let fix = gamut_fix(&mut app, &ctx, "picker-gamut", &blue);
+ assert!(fix.is_some(), "pure blue is out of the default CMYK gamut");
+ // Device CMYK has no gamut to fall outside of.
+ app.run("edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
+ assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), None);
+ app.run("edit.assignProfile", json!({"cmyk": null})).unwrap();
+ assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), fix);
+ app.run("edit.colorSettings", json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
+ assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), None);
+ app.run("edit.colorSettings", json!({"cmyk": cms::GENERIC_CMYK})).unwrap();
+ assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &blue), fix);
+}
+
+/// Loading a new file of the document's assigned CMYK profile, under the same name, updates the
+/// cached warning for the same color.
+#[test]
+fn gamut_warning_follows_a_profile_loaded_again() {
+ const CHILD: &str = "VECTORCRAFT_TEST_GAMUT_PROFILE_RELOAD";
+ if std::env::var_os(CHILD).is_none() {
+ // Color settings and loaded profiles are process-wide: change them in a separate test process.
+ let output = std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", "dialogs::color_picker::tests::gamut_warning_follows_a_profile_loaded_again", "--nocapture"])
+ .env(CHILD, "1")
+ .output()
+ .unwrap();
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
+ "isolated gamut test failed:\n{stdout}\n{}",
+ String::from_utf8_lossy(&output.stderr),
+ );
+ return;
+ }
+ use vectorcraft_color::cms;
+ // A built-in CMYK profile's file renamed `name`: padded with spaces, which names drop.
+ let renamed = |builtin: &str, name: &str| {
+ let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>();
+ let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec();
+ let (from, to) = (utf16(builtin), utf16(&format!("{name: {
- let mut params = json!({ "format": f.id, "path": path, "useArtboards": use_artboards });
- if use_artboards {
- params["range"] = json!(range);
+ let mut params = json!({ "format": f.id, "path": path });
+ // A format that holds the whole document (a template) has no Use Artboards: the
+ // export is not given the option, which its warnings would name as not used.
+ if plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards") {
+ params["useArtboards"] = json!(use_artboards);
+ if use_artboards {
+ params["range"] = json!(range);
+ }
}
if let Some(plugin) = plugin.filter(|p| !p.manifest().params.is_empty()) {
plugin::open_export(app, &plugin, params);
diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs
index 590b93a2fa..c7a74e3178 100644
--- a/crates/ui-egui/src/font_menu.rs
+++ b/crates/ui-egui/src/font_menu.rs
@@ -55,6 +55,8 @@ pub enum WheelOwner {
Control,
/// The Character popover attached to this Control bar link.
ControlPopover(egui::Id),
+ /// The Contextual Task Bar's type controls.
+ TaskBar,
}
impl WheelOwner {
@@ -68,6 +70,12 @@ impl WheelOwner {
Self::ControlPopover(anchor) => {
return app.ui.control_bar && app.ui.screen_mode < 2 && widgets::popover_is_open(ctx, anchor);
}
+ Self::TaskBar => {
+ return app.ui.task_bar
+ && app.ui.screen_mode < 3
+ && crate::canvas::task_bar_shows_type(app)
+ && crate::canvas::task_bar_shows_fonts(ctx);
+ }
Self::Character => "character",
Self::Properties => {
let selected = app.session.active().and_then(|document| document.selection.objects.first().and_then(|id| document.doc.node(*id)));
@@ -106,12 +114,24 @@ pub struct MenuLook<'a> {
pub wheel: bool,
/// Its owner, so a field hidden before the next layout cannot capture wheel input.
pub wheel_owner: Option,
+ /// The name screen readers give the closed field when no label stands beside it.
+ pub name: Option<&'a str>,
}
impl Default for MenuLook<'static> {
/// Samples on in the theme's colors, English names, medium rows, no favorites, no wheel.
fn default() -> Self {
- Self { samples: true, on_white: false, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None }
+ Self {
+ samples: true,
+ on_white: false,
+ english_names: true,
+ row: ROW,
+ favorites: &[],
+ recent: &[],
+ wheel: false,
+ wheel_owner: None,
+ name: None,
+ }
}
}
@@ -131,6 +151,7 @@ impl<'a> MenuLook<'a> {
recent: app.recent_fonts(),
wheel: false,
wheel_owner: None,
+ name: None,
}
}
}
@@ -382,7 +403,7 @@ pub fn font_menu(
// The search field and filters stay in place over the rows, which scroll. The rows are all
// one height (a family's styles too), so only those in view are laid out: there are thousands
// of families.
- let (field, shown) = widgets::combo_menu_field(ui, &id, current, width, |menu| {
+ let (field, shown) = widgets::combo_menu_field_named(ui, &id, look.name, current, width, |menu| {
menu.height(height).show_rows_with_header(
|ui| {
// Wider than a narrow field, so the filters keep their rows and long names show.
diff --git a/crates/ui-egui/src/free_transform.rs b/crates/ui-egui/src/free_transform.rs
index dbebedffa2..9240c1e529 100644
--- a/crates/ui-egui/src/free_transform.rs
+++ b/crates/ui-egui/src/free_transform.rs
@@ -1,7 +1,7 @@
//! The Free Transform widget (#598): while the Free Transform tool is active, a small floating box
//! at the canvas's top left with Constrain (as holding Shift: proportional scaling, moves and
-//! rotations by 45°) over the tool's three modes, Free Transform, Perspective Distort and Free
-//! Distort. It sets the tool's options through `tool.setOption` (`constrain`, `mode`), as agents
+//! rotations by 45°, and Free Distort corners kept on their diagonal) over the tool's three modes,
+//! Free Transform, Perspective Distort and Free Distort. It sets the tool's options through `tool.setOption` (`constrain`, `mode`), as agents
//! do; the modifier keys keep working while dragging.
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
@@ -61,8 +61,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &Ui, canvas: Rect) {
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(60) })
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
- // Constrain shapes only what the Free Transform mode does.
- if button(ui, Glyph::Icon("link"), tl!("Constrain"), constrain, mode == "free") {
+ // Constrain shapes what the Free Transform and Free Distort modes do.
+ if button(ui, Glyph::Icon("link"), tl!("Constrain"), constrain, mode != "perspective") {
set = Some(("constrain", json!(!constrain)));
}
let (line, _) = ui.allocate_exact_size(vec2(BUTTON, 3.0), Sense::hover());
@@ -148,7 +148,8 @@ mod tests {
}
/// The widget shows with the Free Transform tool only; its buttons set the mode and Constrain
- /// (inert in the distort modes); a corner dragged in Free Distort moves that corner alone.
+ /// (inert in Perspective Distort); a corner dragged in Free Distort moves that corner alone, and
+ /// with Constrain on it keeps to the diagonal through the opposite corner.
#[test]
fn the_widget_sets_the_mode_and_a_corner_drag_then_distorts() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -175,26 +176,50 @@ mod tests {
assert_eq!(options(&app), ("free".into(), true), "Constrain on");
click(&mut app, &ctx, at(3));
assert_eq!(options(&app), ("distort".into(), true));
- click(&mut app, &ctx, at(0));
- assert_eq!(options(&app), ("distort".into(), true), "Constrain is inert in Free Distort");
click(&mut app, &ctx, at(2));
assert_eq!(options(&app).0, "perspective");
+ click(&mut app, &ctx, at(0));
+ assert_eq!(options(&app), ("perspective".into(), true), "Constrain is inert in Perspective Distort");
click(&mut app, &ctx, at(3));
- // Free Distort: the top-left corner dragged alone.
+ // Free Distort with Constrain on: the top-left corner dragged by (-20, -10) keeps to the
+ // diagonal (-100, -60), so it moves by 2,600 / 13,600 of it, about (-19.1, -11.5).
let xf = Xf::new(canvas, app.view().unwrap());
- let (from, to) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(80.0, 90.0)));
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
- frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
- frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) / 2.0)]);
- frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
- frame(&mut app, &ctx, vec![button(to, false)]);
- frame(&mut app, &ctx, vec![]);
- let st = app.session.active().unwrap();
- let node = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap();
- let pts: Vec = node.path_data().unwrap().anchors().map(|(.., a)| a.p).collect();
- let near = |q: Point| pts.iter().any(|p| p.distance(q) < 0.5);
+ let drag = |app: &mut VectorcraftApp, from: Point, to: Point| {
+ let (from, to) = (xf.to_screen(from), xf.to_screen(to));
+ frame(app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
+ frame(app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) / 2.0)]);
+ frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
+ frame(app, &ctx, vec![button(to, false)]);
+ frame(app, &ctx, vec![]);
+ };
+ let corners = |app: &VectorcraftApp| -> Vec {
+ let st = app.session.active().unwrap();
+ let node = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap();
+ node.path_data().unwrap().anchors().map(|(.., a)| a.p).collect()
+ };
+ let has = |pts: &[Point], q: Point| pts.iter().any(|p| p.distance(q) < 0.5);
+ drag(&mut app, Point::new(100.0, 100.0), Point::new(80.0, 90.0));
+ let k = (20.0 * 100.0 + 10.0 * 60.0) / (100.0f64 * 100.0 + 60.0 * 60.0);
+ let pts = corners(&app);
assert!(
- near(Point::new(80.0, 90.0)) && near(Point::new(200.0, 100.0)) && near(Point::new(200.0, 160.0)) && near(Point::new(100.0, 160.0)),
+ has(&pts, Point::new(100.0 - 100.0 * k, 100.0 - 60.0 * k))
+ && has(&pts, Point::new(200.0, 100.0))
+ && has(&pts, Point::new(200.0, 160.0))
+ && has(&pts, Point::new(100.0, 160.0)),
+ "{pts:?}"
+ );
+ app.run("edit.undo", json!({})).unwrap();
+ // Constrain off in Free Distort: the corner moves with the pointer.
+ click(&mut app, &ctx, at(0));
+ assert_eq!(options(&app), ("distort".into(), false), "Constrain works in Free Distort");
+ drag(&mut app, Point::new(100.0, 100.0), Point::new(80.0, 90.0));
+ let pts = corners(&app);
+ assert!(
+ has(&pts, Point::new(80.0, 90.0))
+ && has(&pts, Point::new(200.0, 100.0))
+ && has(&pts, Point::new(200.0, 160.0))
+ && has(&pts, Point::new(100.0, 160.0)),
"{pts:?}"
);
assert_eq!(app.session.tool_id(), TOOL);
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index ff72685662..4c2785956f 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -5406,6 +5406,8 @@ lighting Light Светлина
Pen Перо
Pentagram Петограм
Perceptual Перцептивен
+ Hue Clockwise Оттенък по часовниковата стрелка
+ Hue Counterclockwise Оттенък обратно на часовниковата стрелка
Phone 360×800 Телефон 360×800
Phone 375×667 Телефон 375×667
Phone 375×812 Телефон 375×812
@@ -5975,7 +5977,7 @@ axis Y Axis Y ос
@msg `dither` applies to gradient fills (stroke: false) `dither` се прилага към градиентни запълки (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` се прилага към градиентни запълки (paint.setFill)
@msg `dither` must be true or false `dither` трябва да бъде true или false
-@msg `method` must be classic or perceptual `method` трябва да е classic или perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` трябва да е classic, perceptual, hue-clockwise или hue-counterclockwise
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Да се инсталира ли „{name}“, който иска голям изчислителен бюджет?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Всяко изпълнение може да продължи до {minutes} минути и да свърши {times} пъти повече работа, отколкото е позволено на другите приставки. То се изпълнява във фонов режим и можете да го откажете.
@@ -6016,6 +6018,7 @@ axis Y Axis Y ос
Continue Продължаване
Continue Action Продължаване на действието
Delete Action Step Изтриване на стъпка от действието
+ Move Action Step Преместване на стъпка от действието
Press Play Current Selection in the Actions panel to go on. За да продължите, натиснете „Изпълнение на текущия избор“ в панела „Действия“.
Stop: {message} Спиране: {message}
@msg no action is waiting at a stop няма действие, което чака при спиране
@@ -6176,6 +6179,22 @@ axis Y Axis Y ос
Graphite Pencil Графитен молив
Wax Crayon Восъчен пастел
Charcoal Dust Въглен прах
+ Notched Ribbon Назъбена лента
+ Double Line Двойна линия
+ Wave Вълна
+ Teardrop Сълза
+ Striped Ribbon Раирана лента
+ Fine Nib Тънък писец
+ Broad Nib Широк писец
+ Chisel Длето
+ Round Pen Кръгло перо
+ Pressure Pen Перо с натиск
+ Beads Мъниста
+ Dash Dot Тире-точка
+ Scallops Фестони
+ Stars Звезди
+ Triangles Триъгълници
+ Sparkles Искри
Save Brush Library… Запази библиотека с четки…
@msg no empty artboards to delete няма празни работни области за изтриване
Scale Presets Пресети за мащаб
diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv
index 0c77f90ad8..9ccc94d3a4 100644
--- a/crates/ui-egui/src/i18n/bs.tsv
+++ b/crates/ui-egui/src/i18n/bs.tsv
@@ -5503,6 +5503,8 @@ imageTrace Outline Obris
Vivid Živo
Spectrum Spektar
Perceptual Perceptivno
+ Hue Clockwise Nijansa u smjeru kazaljke na satu
+ Hue Counterclockwise Nijansa suprotno od kazaljke na satu
Relative Colorimetric Relativna kolorimetrija
Absolute Colorimetric Apsolutna kolorimetrija
Exact Tačno
@@ -5994,7 +5996,7 @@ axis Y Axis Y osa
@msg `dither` applies to gradient fills (stroke: false) `dither` se primjenjuje na gradijentne ispune (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` se primjenjuje na gradijentne ispune (paint.setFill)
@msg `dither` must be true or false `dither` mora biti true ili false
-@msg `method` must be classic or perceptual `method` mora biti classic ili perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` mora biti classic, perceptual, hue-clockwise ili hue-counterclockwise
Install “{name}”, which asks for a heavy budget? Instalirati „{name}”, koji traži veliki budžet?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Svako pokretanje može trajati do {minutes} minuta i obaviti {times} puta više posla nego drugi dodaci. Radi u pozadini i možete ga otkazati.
It may take minutes, so it runs in the background, where you can cancel it. Može trajati minutama, pa radi u pozadini, gdje ga možete otkazati.
@@ -6058,6 +6060,7 @@ axis Y Axis Y osa
Continue Nastavi
Continue Action Nastavi akciju
Delete Action Step Izbriši korak akcije
+ Move Action Step Premjesti 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
@@ -6229,6 +6232,22 @@ axis Y Axis Y osa
Graphite Pencil Grafitna olovka
Wax Crayon Voštana bojica
Charcoal Dust Ugljena prašina
+ Notched Ribbon Zarezana traka
+ Double Line Dvostruka linija
+ Wave Talas
+ Teardrop Suza
+ Striped Ribbon Prugasta traka
+ Fine Nib Tanki vrh
+ Broad Nib Široki vrh
+ Chisel Dlijeto
+ Round Pen Okruglo pero
+ Pressure Pen Pero na pritisak
+ Beads Perle
+ Dash Dot Crta-tačka
+ Scallops Festoni
+ Stars Zvijezde
+ Triangles Trouglovi
+ Sparkles Iskrice
Save Brush Library… Sačuvaj biblioteku četkica…
@msg `{_1}` has no brushes to apply `{_1}` nema četkica za primjenu
@msg missing `library` (see brush.libraries) nedostaje `library` (pogledajte brush.libraries)
diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv
index 6601948ba6..b0159ebc14 100644
--- a/crates/ui-egui/src/i18n/ca.tsv
+++ b/crates/ui-egui/src/i18n/ca.tsv
@@ -3144,6 +3144,8 @@ digits Malayalam Malaiàlam
per slice per tall
Percent: Percentatge:
Perceptual Perceptual
+ Hue Clockwise To en sentit horari
+ Hue Counterclockwise To en sentit antihorari
Perceptual Scales Escales perceptuals
Performance Rendiment
Permissions Permisos
@@ -3928,6 +3930,22 @@ digits Malayalam Malaiàlam
Graphite Pencil Llapis de grafit
Wax Crayon Cera de colors
Charcoal Dust Pols de carbó
+ Notched Ribbon Cinta dentada
+ Double Line Línia doble
+ Wave Ona
+ Teardrop Llàgrima
+ Striped Ribbon Cinta ratllada
+ Fine Nib Plomí fi
+ Broad Nib Plomí ample
+ Chisel Escarpra
+ Round Pen Ploma rodona
+ Pressure Pen Ploma de pressió
+ Beads Perles
+ Dash Dot Ratlla i punt
+ Scallops Festons
+ Stars Estrelles
+ Triangles Triangles
+ Sparkles Espurnes
Scatter: Dispersió:
Scissors Tisores
Scissors Tool Eina de tisores
@@ -4054,6 +4072,7 @@ digits Malayalam Malaiàlam
Continue Continua
Continue Action Continua l’acció
Delete Action Step Suprimeix el pas de l’acció
+ Move Action Step Mou 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
@@ -5196,7 +5215,7 @@ digits Malayalam Malaiàlam
@msg `ids` must be an array of object ids, not {v} `ids` ha de ser una matriu d’identificadors d’objecte, no {v}
@msg `colors` must be an array of colors, not {v} `colors` ha de ser una matriu de colors, no {v}
@msg `library` is a library id or name (see {list}) `library` és un identificador o un nom de biblioteca (vegeu {list})
-@msg `method` must be classic or perceptual `method` ha de ser classic o perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` ha de ser classic, perceptual, hue-clockwise o hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` ha de ser pad, repeat o reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` ha de ser un nombre enter d’1 a {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’aplica als degradats lineals, radials, cònics i de rombe
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index baef0a88e6..27b2d32771 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -5714,6 +5714,8 @@ imageTrace Outline Obrys
Spectrum Spektrum
# Rendering intents, recolor methods, gradient kinds
Perceptual Percepční
+ Hue Clockwise Odstín ve směru hodinových ručiček
+ Hue Counterclockwise Odstín proti směru hodinových ručiček
Relative Colorimetric Relativní kolorimetrický
Absolute Colorimetric Absolutní kolorimetrický
Exact Přesná
@@ -6311,7 +6313,7 @@ axis Y Axis osa Y
@msg `dither` applies to gradient fills (stroke: false) `dither` platí pro výplně přechodem (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` platí pro výplně přechodem (paint.setFill)
@msg `dither` must be true or false `dither` musí být true nebo false
-@msg `method` must be classic or perceptual `method` musí být classic nebo perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` musí být classic, perceptual, hue-clockwise nebo hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` musí být pad, repeat nebo reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musí být celé číslo od 1 do {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` platí pro lineární, kruhové, kuželové a kosočtverečné přechody
@@ -6554,6 +6556,7 @@ axis Y Axis osa Y
Continue Pokračovat
Continue Action Pokračovat v akci
Delete Action Step Odstranit krok akce
+ Move Action Step Přesunout 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í
@@ -6725,6 +6728,22 @@ axis Y Axis osa Y
Graphite Pencil Grafitová tužka
Wax Crayon Vosková pastelka
Charcoal Dust Uhlový prach
+ Notched Ribbon Vroubkovaná stuha
+ Double Line Dvojitá čára
+ Wave Vlna
+ Teardrop Slza
+ Striped Ribbon Pruhovaná stuha
+ Fine Nib Jemný hrot
+ Broad Nib Široký hrot
+ Chisel Dláto
+ Round Pen Kulaté pero
+ Pressure Pen Tlakové pero
+ Beads Korálky
+ Dash Dot Čárka-tečka
+ Scallops Feston
+ Stars Hvězdy
+ Triangles Trojúhelníky
+ Sparkles Jiskry
Save Brush Library… Uložit knihovnu štětců…
@msg `{_1}` has no brushes to apply `{_1}` nemá žádné štětce k použití
@msg missing `library` (see brush.libraries) chybí `library` (viz brush.libraries)
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
index 9192974eec..3810bb2ac8 100644
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -5177,6 +5177,8 @@ imageTrace Outline Kontur
Vivid Intens
Spectrum Spektrum
Perceptual Perceptuel
+ Hue Clockwise Farvetone med uret
+ Hue Counterclockwise Farvetone mod uret
Relative Colorimetric Relativ kolorimetrisk
Absolute Colorimetric Absolut kolorimetrisk
Exact Eksakt
@@ -5991,7 +5993,7 @@ axis Y Axis Y-akse
@msg `dither` applies to gradient fills (stroke: false) `dither` gælder for gradientfyld (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` gælder for gradientfyld (paint.setFill)
@msg `dither` must be true or false `dither` skal være true eller false
-@msg `method` must be classic or perceptual `method` skal være classic eller perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` skal være classic, perceptual, hue-clockwise eller hue-counterclockwise
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Installér »{name}«, som beder om et stort beregningsbudget?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Hver kørsel kan tage op til {minutes} minutter og udføre {times} gange så meget arbejde, som andre plug-ins må. Den kører i baggrunden, og du kan annullere den.
@@ -6032,6 +6034,7 @@ axis Y Axis Y-akse
Continue Fortsæt
Continue Action Fortsæt handling
Delete Action Step Slet handlingstrin
+ Move Action Step Flyt 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
@@ -6192,6 +6195,22 @@ axis Y Axis Y-akse
Graphite Pencil Grafitblyant
Wax Crayon Vokskridt
Charcoal Dust Kulstøv
+ Notched Ribbon Takket bånd
+ Double Line Dobbelt linje
+ Wave Bølge
+ Teardrop Dråbe
+ Striped Ribbon Stribet bånd
+ Fine Nib Fin pen
+ Broad Nib Bred pen
+ Chisel Mejsel
+ Round Pen Rund pen
+ Pressure Pen Trykpen
+ Beads Perler
+ Dash Dot Streg-prik
+ Scallops Bølgekant
+ Stars Stjerner
+ Triangles Trekanter
+ Sparkles Gnister
Save Brush Library… Gem penselbibliotek…
@msg no empty artboards to delete ingen tomme tegneflader at slette
Scale Presets Skaleringsforudindstillinger
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 78e7fe186b..7b6cd3e8a6 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -5903,7 +5903,9 @@ axis Y Axis Y-Achse
@msg `dither` applies to gradient fills (paint.setFill) `dither` gilt für Verlaufsflächen (paint.setFill)
@msg `dither` must be true or false `dither` muss true oder false sein
Perceptual Perzeptiv
-@msg `method` must be classic or perceptual `method` muss classic oder perceptual sein
+ Hue Clockwise Farbton im Uhrzeigersinn
+ Hue Counterclockwise Farbton gegen den Uhrzeigersinn
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` muss classic, perceptual, hue-clockwise oder hue-counterclockwise sein
@msg `spread` must be pad, repeat or reflect `spread` muss pad, repeat oder reflect sein
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` muss eine ganze Zahl von 1 bis {MAX_REPEATS} sein
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` gilt für lineare, radiale, konische Verläufe und Rautenverläufe
@@ -6146,6 +6148,7 @@ axis Y Axis Y-Achse
Continue Fortsetzen
Continue Action Aktion fortsetzen
Delete Action Step Aktionsschritt löschen
+ Move Action Step Aktionsschritt verschieben
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
@@ -6317,6 +6320,22 @@ axis Y Axis Y-Achse
Graphite Pencil Graphitstift
Wax Crayon Wachsmalstift
Charcoal Dust Kohlestaub
+ Notched Ribbon Gekerbtes Band
+ Double Line Doppellinie
+ Wave Welle
+ Teardrop Tropfen
+ Striped Ribbon Gestreiftes Band
+ Fine Nib Feine Feder
+ Broad Nib Breite Feder
+ Chisel Meißel
+ Round Pen Runde Feder
+ Pressure Pen Druckfeder
+ Beads Perlen
+ Dash Dot Strich-Punkt
+ Scallops Bogenkante
+ Stars Sterne
+ Triangles Dreiecke
+ Sparkles Funkeln
Save Brush Library… Pinsel-Bibliothek speichern…
@msg `{_1}` has no brushes to apply `{_1}` hat keine Pinsel zum Anwenden
@msg missing `library` (see brush.libraries) `library` fehlt (siehe brush.libraries)
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 82f265b702..8620169f21 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -2748,6 +2748,8 @@ lighting Light Φως
Oval Οβάλ
Pen Πένα (Pen Tool)
Perceptual Αντιληπτικό
+ Hue Clockwise Απόχρωση δεξιόστροφα
+ Hue Counterclockwise Απόχρωση αριστερόστροφα
Pixel Εικονοστοιχείο
Points: Σημεία:
Preserve Διατήρηση
@@ -6263,7 +6265,7 @@ axis Y Axis Άξονας Y
@msg `dither` applies to gradient fills (stroke: false) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (paint.setFill)
@msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false
-@msg `method` must be classic or perceptual Το `method` πρέπει να είναι classic ή perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise Το `method` πρέπει να είναι classic, perceptual, hue-clockwise ή hue-counterclockwise
@msg `spread` must be pad, repeat or reflect Το `spread` πρέπει να είναι pad, repeat ή reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} Το `repeats` πρέπει να είναι ακέραιος από 1 έως {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients Το `spread` ισχύει για γραμμικά, ακτινικά, κωνικά και ρομβοειδή ντεγκραντέ
@@ -6506,6 +6508,7 @@ axis Y Axis Άξονας Y
Continue Συνέχεια
Continue Action Συνέχεια ενέργειας
Delete Action Step Διαγραφή βήματος ενέργειας
+ Move Action Step Μετακίνηση βήματος ενέργειας
Press Play Current Selection in the Actions panel to go on. Για να συνεχίσετε, πατήστε «Αναπαραγωγή τρέχουσας επιλογής» στο πάνελ Ενέργειες.
Stop: {message} Διακοπή: {message}
@msg no action is waiting at a stop καμία ενέργεια δεν περιμένει σε διακοπή
@@ -6677,6 +6680,22 @@ axis Y Axis Άξονας Y
Graphite Pencil Μολύβι γραφίτη
Wax Crayon Κηρομπογιά
Charcoal Dust Σκόνη κάρβουνου
+ Notched Ribbon Οδοντωτή κορδέλα
+ Double Line Διπλή γραμμή
+ Wave Κύμα
+ Teardrop Δάκρυ
+ Striped Ribbon Ριγέ κορδέλα
+ Fine Nib Λεπτή πένα
+ Broad Nib Φαρδιά πένα
+ Chisel Σμίλη
+ Round Pen Στρογγυλή πένα
+ Pressure Pen Πένα πίεσης
+ Beads Χάντρες
+ Dash Dot Παύλα-τελεία
+ Scallops Φεστόνια
+ Stars Αστέρια
+ Triangles Τρίγωνα
+ Sparkles Σπίθες
Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων…
@msg `{_1}` has no brushes to apply Η `{_1}` δεν έχει πινέλα για εφαρμογή
@msg missing `library` (see brush.libraries) λείπει το `library` (δείτε brush.libraries)
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index e0aaffde87..a82db20a99 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -5630,6 +5630,8 @@ effect Regular Regular
High Contrast 3 Alto contraste 3
Pentagram Pentagrama
Perceptual Perceptual
+ Hue Clockwise Tono en sentido horario
+ Hue Counterclockwise Tono en sentido antihorario
Relative Colorimetric Colorimétrico relativo
Absolute Colorimetric Colorimétrico absoluto
Exact Exacto
@@ -6254,7 +6256,7 @@ axis Y Axis Eje Y
@msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a rellenos de degradado (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a rellenos de degradado (paint.setFill)
@msg `dither` must be true or false `dither` debe ser true o false
-@msg `method` must be classic or perceptual `method` debe ser classic o perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` debe ser classic, perceptual, hue-clockwise o hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` debe ser pad, repeat o reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` debe ser un número entero de 1 a {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a los degradados lineales, radiales, cónicos y de rombo
@@ -6497,6 +6499,7 @@ axis Y Axis Eje Y
Continue Continuar
Continue Action Continuar acción
Delete Action Step Eliminar paso de la acción
+ Move Action Step Mover 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
@@ -6668,6 +6671,22 @@ axis Y Axis Eje Y
Graphite Pencil Lápiz de grafito
Wax Crayon Cera de colores
Charcoal Dust Polvo de carboncillo
+ Notched Ribbon Cinta dentada
+ Double Line Línea doble
+ Wave Onda
+ Teardrop Lágrima
+ Striped Ribbon Cinta a rayas
+ Fine Nib Plumilla fina
+ Broad Nib Plumilla ancha
+ Chisel Cincel
+ Round Pen Pluma redonda
+ Pressure Pen Pluma de presión
+ Beads Cuentas
+ Dash Dot Raya y punto
+ Scallops Festones
+ Stars Estrellas
+ Triangles Triángulos
+ Sparkles Destellos
Save Brush Library… Guardar biblioteca de pinceles…
@msg `{_1}` has no brushes to apply `{_1}` no tiene pinceles para aplicar
@msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries)
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
index 1ea537adbe..db90a36316 100644
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -5606,6 +5606,8 @@ imageTrace Outline Ääriviiva
Vivid Eloisa
Spectrum Spektri
Perceptual Havainnollinen
+ Hue Clockwise Sävy myötäpäivään
+ Hue Counterclockwise Sävy vastapäivään
Relative Colorimetric Suhteellinen kolorimetrinen
Absolute Colorimetric Absoluuttinen kolorimetrinen
Exact Tarkka
@@ -6341,7 +6343,7 @@ axis Y Axis Y-akseli
@msg `dither` applies to gradient fills (stroke: false) `dither` koskee liukuväritäyttöjä (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` koskee liukuväritäyttöjä (paint.setFill)
@msg `dither` must be true or false `dither` on oltava true tai false
-@msg `method` must be classic or perceptual `method` on oltava classic tai perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` on oltava classic, perceptual, hue-clockwise tai hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` on oltava pad, repeat tai reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` on oltava kokonaisluku väliltä 1–{MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` koskee lineaarisia, säteittäisiä ja kartiomaisia liukuvärejä sekä vinoneliöliukuvärejä
@@ -6584,6 +6586,7 @@ axis Y Axis Y-akseli
Continue Jatka
Continue Action Jatka toimintoa
Delete Action Step Poista toiminnon vaihe
+ Move Action Step Siirrä 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ä
@@ -6755,6 +6758,22 @@ axis Y Axis Y-akseli
Graphite Pencil Grafiittikynä
Wax Crayon Vahaliitu
Charcoal Dust Hiilipöly
+ Notched Ribbon Lovettu nauha
+ Double Line Kaksoisviiva
+ Wave Aalto
+ Teardrop Pisara
+ Striped Ribbon Raidallinen nauha
+ Fine Nib Ohut kärki
+ Broad Nib Leveä kärki
+ Chisel Taltta
+ Round Pen Pyöreä kynä
+ Pressure Pen Painekynä
+ Beads Helmet
+ Dash Dot Viiva-piste
+ Scallops Kaarireunus
+ Stars Tähdet
+ Triangles Kolmiot
+ Sparkles Kipinät
Save Brush Library… Tallenna sivellinkirjasto…
@msg `{_1}` has no brushes to apply Kohteessa `{_1}` ei ole käytettäviä siveltimiä
@msg missing `library` (see brush.libraries) `library` puuttuu (katso brush.libraries)
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 96d6f7dd13..938e43dd48 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -5777,6 +5777,8 @@ lighting Light Lumière
Pen Plume
Pentagram Pentagramme
Perceptual Perceptif
+ Hue Clockwise Teinte en sens horaire
+ Hue Counterclockwise Teinte en sens antihoraire
Phone 360×800 Téléphone 360×800
Phone 375×667 Téléphone 375×667
Phone 375×812 Téléphone 375×812
@@ -6306,7 +6308,7 @@ axis Y Axis Axe Y
@msg `dither` applies to gradient fills (stroke: false) `dither` s’applique aux fonds en dégradé (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` s’applique aux fonds en dégradé (paint.setFill)
@msg `dither` must be true or false `dither` doit valoir true ou false
-@msg `method` must be classic or perceptual `method` doit être classic ou perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` doit être classic, perceptual, hue-clockwise ou hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` doit être pad, repeat ou reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` doit être un nombre entier de 1 à {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’applique aux dégradés linéaires, radiaux, coniques et en losange
@@ -6549,6 +6551,7 @@ axis Y Axis Axe Y
Continue Continuer
Continue Action Continuer le script
Delete Action Step Supprimer l’étape du script
+ Move Action Step Déplacer 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
@@ -6720,6 +6723,22 @@ axis Y Axis Axe Y
Graphite Pencil Crayon graphite
Wax Crayon Craie grasse
Charcoal Dust Poussière de fusain
+ Notched Ribbon Ruban cranté
+ Double Line Double ligne
+ Wave Vague
+ Teardrop Goutte
+ Striped Ribbon Ruban rayé
+ Fine Nib Plume fine
+ Broad Nib Plume large
+ Chisel Ciseau
+ Round Pen Plume ronde
+ Pressure Pen Plume à pression
+ Beads Perles
+ Dash Dot Tiret-point
+ Scallops Festons
+ Stars Étoiles
+ Triangles Triangles
+ Sparkles Étincelles
Save Brush Library… Enregistrer la bibliothèque de formes…
@msg `{_1}` has no brushes to apply `{_1}` n'a aucune forme à appliquer
@msg missing `library` (see brush.libraries) `library` manquant (voir brush.libraries)
diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv
index b9401b2b77..0eacf9629b 100644
--- a/crates/ui-egui/src/i18n/hi.tsv
+++ b/crates/ui-egui/src/i18n/hi.tsv
@@ -444,6 +444,22 @@ antiAlias Sharp शार्प
Graphite Pencil ग्रेफाइट पेंसिल
Wax Crayon मोम क्रेयॉन
Charcoal Dust चारकोल धूल
+ Notched Ribbon नोकदार रिबन
+ Double Line दोहरी लाइन
+ Wave लहर
+ Teardrop आंसू की बूंद
+ Striped Ribbon धारीदार रिबन
+ Fine Nib बारीक निब
+ Broad Nib चौड़ी निब
+ Chisel छेनी
+ Round Pen गोल पेन
+ Pressure Pen प्रेशर पेन
+ Beads मोती
+ Dash Dot डैश डॉट
+ Scallops स्कैलप
+ Stars तारे
+ Triangles त्रिभुज
+ Sparkles चमक
Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है।
Artboard आर्टबोर्ड
Artboard {n} of {total} {total} में से आर्टबोर्ड {n}
@@ -3412,6 +3428,8 @@ antiAlias Sharp शार्प
per slice प्रति स्लाइस
Percent: प्रतिशत:
Perceptual परसेप्चुअल
+ Hue Clockwise ह्यू घड़ी की दिशा में
+ Hue Counterclockwise ह्यू घड़ी की विपरीत दिशा में
Perceptual Scales अवधारणात्मक स्केल
Performance प्रदर्शन
Permissions अनुमतियां
@@ -3480,6 +3498,7 @@ antiAlias Sharp शार्प
Continue जारी रखें
Continue Action एक्शन जारी रखें
Delete Action Step एक्शन का चरण हटाएं
+ Move Action Step एक्शन का चरण खिसकाएं
Press Play Current Selection in the Actions panel to go on. आगे बढ़ने के लिए एक्शन्स पैनल में वर्तमान सिलेक्शन प्ले करें दबाएँ।
Stop: {message} स्टॉप: {message}
@msg no action is waiting at a stop कोई एक्शन स्टॉप पर प्रतीक्षा नहीं कर रहा है
@@ -6077,7 +6096,7 @@ antiAlias Sharp शार्प
@msg `length` must be a number of at most 1000000 pt either way `length` को 1000000 pt में से एक होना चाहिए।
@msg `library` is a library id or name (see {list}) `library` एक लाइब्रेरी आईडी या नाम है ( {list} देखें)
@msg `line` must be true or false `line` को true या false होना चाहिए
-@msg `method` must be classic or perceptual `method` क्लासिक या अवधारणात्मक होना चाहिए
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` classic, perceptual, hue-clockwise या hue-counterclockwise होना चाहिए
@msg `spread` must be pad, repeat or reflect `spread` pad, repeat या reflect होना चाहिए
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 से {MAX_REPEATS} तक की पूर्ण संख्या होनी चाहिए
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` रैखिक, रेडियल, शंक्वाकार और डायमंड ग्रेडिएंट्स पर लागू होता है
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 89b517cdb2..3dadc4d3b6 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -3032,6 +3032,8 @@ file-filter Templates Sablonok
per slice szeletenként
Percent: Százalék:
Perceptual Észlelési
+ Hue Clockwise Színárnyalat az óramutató járásával megegyezően
+ Hue Counterclockwise Színárnyalat az óramutató járásával ellentétesen
Perceptual Scales Észlelési skálák
Performance Teljesítmény
diagnostics render renderelés
@@ -6294,7 +6296,7 @@ axis Y Axis Y tengely
@msg `dither` applies to gradient fills (stroke: false) A `dither` színátmenetes kitöltésekre vonatkozik (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) A `dither` színátmenetes kitöltésekre vonatkozik (paint.setFill)
@msg `dither` must be true or false `dither`: true vagy false kell
-@msg `method` must be classic or perceptual a `method` értéke classic vagy perceptual lehet
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise a `method` értéke classic, perceptual, hue-clockwise vagy hue-counterclockwise lehet
@msg `spread` must be pad, repeat or reflect a `spread` értéke pad, repeat vagy reflect lehet
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} a `repeats` értéke 1 és {MAX_REPEATS} közötti egész szám lehet
@msg `spread` applies to linear, radial, conic and diamond gradients a `spread` a lineáris, sugaras, kúpos és rombusz színátmenetekre vonatkozik
@@ -6537,6 +6539,7 @@ axis Y Axis Y tengely
Continue Folytatás
Continue Action Művelet folytatása
Delete Action Step Műveletlépés törlése
+ Move Action Step Műveletlépés áthelyezé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
@@ -6708,6 +6711,22 @@ axis Y Axis Y tengely
Graphite Pencil Grafitceruza
Wax Crayon Zsírkréta
Charcoal Dust Szénpor
+ Notched Ribbon Csipkézett szalag
+ Double Line Dupla vonal
+ Wave Hullám
+ Teardrop Csepp
+ Striped Ribbon Csíkos szalag
+ Fine Nib Finom tollhegy
+ Broad Nib Széles tollhegy
+ Chisel Véső
+ Round Pen Kerek toll
+ Pressure Pen Nyomásérzékeny toll
+ Beads Gyöngyök
+ Dash Dot Pont-vonal
+ Scallops Hullámos szegély
+ Stars Csillagok
+ Triangles Háromszögek
+ Sparkles Szikrák
Save Brush Library… Ecsetkönyvtár mentése…
@msg `{_1}` has no brushes to apply A(z) `{_1}` nem tartalmaz alkalmazható ecsetet
@msg missing `library` (see brush.libraries) hiányzik a `library` (lásd: brush.libraries)
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
index 543723217f..e670840b2c 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -5177,6 +5177,8 @@ imageTrace Outline Kontur
Vivid Cerah
Spectrum Spektrum
Perceptual Perseptual
+ Hue Clockwise Rona searah jarum jam
+ Hue Counterclockwise Rona berlawanan arah jarum jam
Relative Colorimetric Kolorimetrik relatif
Absolute Colorimetric Kolorimetrik absolut
Exact Eksak
@@ -5991,7 +5993,7 @@ axis Y Axis Sumbu Y
@msg `dither` applies to gradient fills (stroke: false) `dither` berlaku untuk isian gradien (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` berlaku untuk isian gradien (paint.setFill)
@msg `dither` must be true or false `dither` harus true atau false
-@msg `method` must be classic or perceptual `method` harus classic atau perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` harus classic, perceptual, hue-clockwise atau hue-counterclockwise
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Instal “{name}”, yang meminta anggaran komputasi besar?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Setiap kali dijalankan, plug-in ini bisa memakan waktu hingga {minutes} menit dan melakukan {times} kali pekerjaan yang diizinkan untuk plug-in lain. Plug-in ini berjalan di latar belakang, dan Anda dapat membatalkannya.
@@ -6032,6 +6034,7 @@ axis Y Axis Sumbu Y
Continue Lanjutkan
Continue Action Lanjutkan tindakan
Delete Action Step Hapus langkah tindakan
+ Move Action Step Pindahkan 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
@@ -6192,6 +6195,22 @@ axis Y Axis Sumbu Y
Graphite Pencil Pensil grafit
Wax Crayon Krayon lilin
Charcoal Dust Debu arang
+ Notched Ribbon Pita bertakik
+ Double Line Garis ganda
+ Wave Gelombang
+ Teardrop Tetesan
+ Striped Ribbon Pita bergaris
+ Fine Nib Mata pena halus
+ Broad Nib Mata pena lebar
+ Chisel Pahat
+ Round Pen Pena bulat
+ Pressure Pen Pena tekanan
+ Beads Manik-manik
+ Dash Dot Garis titik
+ Scallops Lekuk kerang
+ Stars Bintang
+ Triangles Segitiga
+ Sparkles Kilauan
Save Brush Library… Simpan pustaka kuas…
@msg no empty artboards to delete tidak ada artboard kosong untuk dihapus
Scale Presets Preset skala
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index 2b17326766..91d1e24bf7 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -5937,7 +5937,9 @@ axis Y Axis Asse Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` si applica ai riempimenti sfumati (paint.setFill)
@msg `dither` must be true or false `dither` deve essere true o false
Perceptual Percettivo
-@msg `method` must be classic or perceptual `method` deve essere classic o perceptual
+ Hue Clockwise Tonalità in senso orario
+ Hue Counterclockwise Tonalità in senso antiorario
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` deve essere classic, perceptual, hue-clockwise o hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` deve essere pad, repeat o reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve essere un numero intero da 1 a {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` si applica alle sfumature lineari, radiali, coniche e a rombo
@@ -6180,6 +6182,7 @@ axis Y Axis Asse Y
Continue Continua
Continue Action Continua azione
Delete Action Step Elimina passaggio dell’azione
+ Move Action Step Sposta 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
@@ -6351,6 +6354,22 @@ axis Y Axis Asse Y
Graphite Pencil Matita di grafite
Wax Crayon Pastello a cera
Charcoal Dust Polvere di carboncino
+ Notched Ribbon Nastro dentellato
+ Double Line Doppia linea
+ Wave Onda
+ Teardrop Goccia
+ Striped Ribbon Nastro a righe
+ Fine Nib Pennino fine
+ Broad Nib Pennino largo
+ Chisel Scalpello
+ Round Pen Penna tonda
+ Pressure Pen Penna a pressione
+ Beads Perline
+ Dash Dot Tratto-punto
+ Scallops Smerlo
+ Stars Stelle
+ Triangles Triangoli
+ Sparkles Scintille
Save Brush Library… Salva libreria pennelli…
@msg `{_1}` has no brushes to apply `{_1}` non ha pennelli da applicare
@msg missing `library` (see brush.libraries) manca `library` (vedi brush.libraries)
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 3d71e2eb0e..628d1ef3de 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -6003,7 +6003,9 @@ axis Y Axis Y 軸
@msg `dither` applies to gradient fills (paint.setFill) `dither` はグラデーションの塗りに適用されます (paint.setFill)
@msg `dither` must be true or false `dither` には true または false を指定してください
Perceptual 知覚的
-@msg `method` must be classic or perceptual `method` には classic または perceptual を指定してください
+ Hue Clockwise 時計回りの色相
+ Hue Counterclockwise 反時計回りの色相
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` には classic、perceptual、hue-clockwise または hue-counterclockwise を指定してください
@msg `spread` must be pad, repeat or reflect `spread` には pad、repeat または reflect を指定してください
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` には 1 から {MAX_REPEATS} までの整数を指定してください
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` は線形、円形、円錐およびひし形グラデーションに適用されます
@@ -6246,6 +6248,7 @@ axis Y Axis Y 軸
Continue 続行
Continue Action アクションを続行
Delete Action Step アクションステップを削除
+ Move Action Step アクションステップを移動
Press Play Current Selection in the Actions panel to go on. 続行するには、アクションパネルの「選択項目を再生」を押します。
Stop: {message} 停止: {message}
@msg no action is waiting at a stop 停止で待機中のアクションはありません
@@ -6417,6 +6420,22 @@ axis Y Axis Y 軸
Graphite Pencil 黒鉛鉛筆
Wax Crayon ワックスクレヨン
Charcoal Dust 木炭の粉
+ Notched Ribbon ノッチリボン
+ Double Line 二重線
+ Wave 波
+ Teardrop しずく
+ Striped Ribbon ストライプリボン
+ Fine Nib 細いペン先
+ Broad Nib 太いペン先
+ Chisel 平ノミ
+ Round Pen 丸ペン
+ Pressure Pen 筆圧ペン
+ Beads ビーズ
+ Dash Dot 一点鎖線
+ Scallops スカラップ
+ Stars 星
+ Triangles 三角形
+ Sparkles きらめき
Save Brush Library… ブラシライブラリを保存…
@msg `{_1}` has no brushes to apply `{_1}` に適用できるブラシがありません
@msg missing `library` (see brush.libraries) `library` がありません (brush.libraries を参照)
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
index 0c228dfb32..aa2c27c296 100644
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -5025,6 +5025,8 @@ font-technology Variable ცვლადი
High Contrast 3 მაღალი კონტრასტი 3
Pentagram პენტაგრამა
Perceptual აღქმითი
+ Hue Clockwise ფერის ტონი საათის ისრის მიმართულებით
+ Hue Counterclockwise ფერის ტონი საათის ისრის საწინააღმდეგოდ
Relative Colorimetric ფარდობითი კოლორიმეტრიული
Absolute Colorimetric აბსოლუტური კოლორიმეტრიული
Exact ზუსტი
@@ -6262,7 +6264,7 @@ axis Y Axis Y ღერძი
@msg `dither` applies to gradient fills (stroke: false) `dither` ვრცელდება გრადიენტულ შევსებებზე (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` ვრცელდება გრადიენტულ შევსებებზე (paint.setFill)
@msg `dither` must be true or false `dither` უნდა იყოს true ან false
-@msg `method` must be classic or perceptual `method` უნდა იყოს classic ან perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` უნდა იყოს classic, perceptual, hue-clockwise ან hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` უნდა იყოს pad, repeat ან reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` უნდა იყოს მთელი რიცხვი 1-დან {MAX_REPEATS}-მდე
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` ეხება წრფივ, რადიალურ, კონუსურ და რომბულ გრადიენტებს
@@ -6505,6 +6507,7 @@ axis Y Axis Y ღერძი
Continue გაგრძელება
Continue Action მოქმედების გაგრძელება
Delete Action Step მოქმედების ნაბიჯის წაშლა
+ Move Action Step მოქმედების ნაბიჯის გადატანა
Press Play Current Selection in the Actions panel to go on. გასაგრძელებლად მოქმედებების პანელზე დააჭირეთ „მიმდინარე მონიშნულის შესრულებას“.
Stop: {message} გაჩერება: {message}
@msg no action is waiting at a stop გაჩერებაზე არცერთი მოქმედება არ ელოდება
@@ -6676,6 +6679,22 @@ axis Y Axis Y ღერძი
Graphite Pencil გრაფიტის ფანქარი
Wax Crayon ცვილის ფანქარი
Charcoal Dust ნახშირის მტვერი
+ Notched Ribbon დაკბილული ლენტი
+ Double Line ორმაგი ხაზი
+ Wave ტალღა
+ Teardrop წვეთი
+ Striped Ribbon ზოლიანი ლენტი
+ Fine Nib წვრილი წვერი
+ Broad Nib განიერი წვერი
+ Chisel საჭრეთელი
+ Round Pen მრგვალი კალამი
+ Pressure Pen დაწოლის კალამი
+ Beads მძივები
+ Dash Dot ტირე-წერტილი
+ Scallops ფესტონები
+ Stars ვარსკვლავები
+ Triangles სამკუთხედები
+ Sparkles ნაპერწკლები
Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა…
@msg `{_1}` has no brushes to apply `{_1}`-ში გამოსაყენებელი ფუნჯები არ არის
@msg missing `library` (see brush.libraries) `library` აკლია (იხ. brush.libraries)
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index 53b8b5ed47..ca5c2ac8d1 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -5898,7 +5898,9 @@ axis Y Axis Y축
@msg `dither` applies to gradient fills (paint.setFill) `dither`는 그레이디언트 칠에 적용됩니다 (paint.setFill)
@msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다
Perceptual 지각적
-@msg `method` must be classic or perceptual `method`는 classic 또는 perceptual이어야 합니다
+ Hue Clockwise 색조 시계 방향
+ Hue Counterclockwise 색조 시계 반대 방향
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method`는 classic, perceptual, hue-clockwise 또는 hue-counterclockwise여야 합니다
@msg `spread` must be pad, repeat or reflect `spread`는 pad, repeat 또는 reflect여야 합니다
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats`는 1에서 {MAX_REPEATS} 사이의 정수여야 합니다
@msg `spread` applies to linear, radial, conic and diamond gradients `spread`는 선형, 방사형, 원뿔형 및 다이아몬드 그레이디언트에 적용됩니다
@@ -6141,6 +6143,7 @@ axis Y Axis Y축
Continue 계속
Continue Action 액션 계속
Delete Action Step 액션 단계 삭제
+ Move Action Step 액션 단계 이동
Press Play Current Selection in the Actions panel to go on. 계속하려면 액션 패널에서 현재 선택 항목 재생을 누르세요.
Stop: {message} 중지: {message}
@msg no action is waiting at a stop 중지에서 대기 중인 액션이 없습니다
@@ -6312,6 +6315,22 @@ axis Y Axis Y축
Graphite Pencil 흑연 연필
Wax Crayon 왁스 크레용
Charcoal Dust 목탄 가루
+ Notched Ribbon 노치 리본
+ Double Line 이중선
+ Wave 물결
+ Teardrop 눈물방울
+ Striped Ribbon 줄무늬 리본
+ Fine Nib 가는 펜촉
+ Broad Nib 넓은 펜촉
+ Chisel 끌
+ Round Pen 둥근 펜
+ Pressure Pen 필압 펜
+ Beads 구슬
+ Dash Dot 일점쇄선
+ Scallops 스캘럽
+ Stars 별
+ Triangles 삼각형
+ Sparkles 반짝임
Save Brush Library… 브러시 라이브러리 저장…
@msg `{_1}` has no brushes to apply `{_1}`에 적용할 브러시가 없습니다
@msg missing `library` (see brush.libraries) `library`가 없습니다(brush.libraries 참조)
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
index d38e0c113f..d46d7e8e87 100644
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -5693,6 +5693,8 @@ imageTrace Outline Contour
Vivid Levendig
Spectrum Spectrum
Perceptual Perceptueel
+ Hue Clockwise Tint met de klok mee
+ Hue Counterclockwise Tint tegen de klok in
Relative Colorimetric Relatief colorimetrisch
Absolute Colorimetric Absoluut colorimetrisch
Exact Exact
@@ -6608,7 +6610,7 @@ axis Y Axis Y-as
@msg `dither` applies to gradient fills (stroke: false) `dither` geldt voor verloopvullingen (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` geldt voor verloopvullingen (paint.setFill)
@msg `dither` must be true or false `dither` moet true of false zijn
-@msg `method` must be classic or perceptual `method` moet classic of perceptual zijn
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` moet classic, perceptual, hue-clockwise of hue-counterclockwise zijn
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? „{name}” installeren, dat om een groot rekenbudget vraagt?
Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Elke run kan tot {minutes} minuten duren en {times} keer zoveel werk doen als andere plug-ins mogen. Hij draait op de achtergrond en je kunt hem annuleren.
@@ -6649,6 +6651,7 @@ axis Y Axis Y-as
Continue Doorgaan
Continue Action Actie voortzetten
Delete Action Step Actiestap verwijderen
+ Move Action Step Actiestap verplaatsen
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
@@ -6809,6 +6812,22 @@ axis Y Axis Y-as
Graphite Pencil Grafietpotlood
Wax Crayon Waskrijt
Charcoal Dust Houtskoolstof
+ Notched Ribbon Gekerfd lint
+ Double Line Dubbele lijn
+ Wave Golf
+ Teardrop Traan
+ Striped Ribbon Gestreept lint
+ Fine Nib Fijne penpunt
+ Broad Nib Brede penpunt
+ Chisel Beitel
+ Round Pen Ronde pen
+ Pressure Pen Drukpen
+ Beads Kralen
+ Dash Dot Streep-punt
+ Scallops Schulprand
+ Stars Sterren
+ Triangles Driehoeken
+ Sparkles Fonkelingen
Save Brush Library… Penseelbibliotheek opslaan…
@msg no empty artboards to delete geen lege tekengebieden om te verwijderen
Scale Presets Schaalvoorinstellingen
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index 0ae5431a81..38d5e2f497 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -5566,6 +5566,8 @@ imageTrace Outline Kontur
High Contrast 3 Wysoki kontrast 3
Pentagram Pentagram
Perceptual Percepcyjna
+ Hue Clockwise Barwa zgodnie z ruchem wskazówek zegara
+ Hue Counterclockwise Barwa przeciwnie do ruchu wskazówek zegara
Relative Colorimetric Kolorymetryczna względna
Absolute Colorimetric Kolorymetryczna bezwzględna
Exact Dokładne
@@ -6166,7 +6168,7 @@ axis Y Axis Oś Y
@msg `dither` applies to gradient fills (stroke: false) `dither` dotyczy wypełnień gradientem (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` dotyczy wypełnień gradientem (paint.setFill)
@msg `dither` must be true or false `dither` musi mieć wartość true lub false
-@msg `method` must be classic or perceptual `method` musi mieć wartość classic lub perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` musi mieć wartość classic, perceptual, hue-clockwise lub hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` musi mieć wartość pad, repeat lub reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musi być liczbą całkowitą od 1 do {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` dotyczy gradientów liniowych, radialnych, stożkowych i rombowych
@@ -6409,6 +6411,7 @@ axis Y Axis Oś Y
Continue Kontynuuj
Continue Action Kontynuuj operację
Delete Action Step Usuń krok operacji
+ Move Action Step Przenieś 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
@@ -6580,6 +6583,22 @@ axis Y Axis Oś Y
Graphite Pencil Ołówek grafitowy
Wax Crayon Kredka woskowa
Charcoal Dust Pył węglowy
+ Notched Ribbon Wstęga z nacięciami
+ Double Line Podwójna linia
+ Wave Fala
+ Teardrop Łza
+ Striped Ribbon Wstęga w paski
+ Fine Nib Cienka stalówka
+ Broad Nib Szeroka stalówka
+ Chisel Dłuto
+ Round Pen Okrągłe pióro
+ Pressure Pen Pióro z naciskiem
+ Beads Koraliki
+ Dash Dot Kreska-kropka
+ Scallops Feston
+ Stars Gwiazdy
+ Triangles Trójkąty
+ Sparkles Iskry
Save Brush Library… Zapisz bibliotekę pędzli…
@msg `{_1}` has no brushes to apply `{_1}` nie ma pędzli do zastosowania
@msg missing `library` (see brush.libraries) brak `library` (zobacz brush.libraries)
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index 854894cb68..2a45bc8e68 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -5641,6 +5641,8 @@ effect Regular Regular
High Contrast 3 Alto Contraste 3
Pentagram Pentagrama
Perceptual Perceptual
+ Hue Clockwise Tom em sentido horário
+ Hue Counterclockwise Tom em sentido anti-horário
Relative Colorimetric Colorimétrico Relativo
Absolute Colorimetric Colorimétrico Absoluto
Exact Exato
@@ -6226,7 +6228,7 @@ axis Y Axis Eixo Y
@msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a preenchimentos em gradiente (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a preenchimentos em gradiente (paint.setFill)
@msg `dither` must be true or false `dither` deve ser true ou false
-@msg `method` must be classic or perceptual `method` deve ser classic ou perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` deve ser classic, perceptual, hue-clockwise ou hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` deve ser pad, repeat ou reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve ser um número inteiro de 1 a {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a gradientes lineares, radiais, cônicos e em losango
@@ -6469,6 +6471,7 @@ axis Y Axis Eixo Y
Continue Continuar
Continue Action Continuar Ação
Delete Action Step Excluir Etapa da Ação
+ Move Action Step Mover 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
@@ -6640,6 +6643,22 @@ axis Y Axis Eixo Y
Graphite Pencil Lápis de Grafite
Wax Crayon Giz de Cera
Charcoal Dust Pó de Carvão
+ Notched Ribbon Fita Entalhada
+ Double Line Linha Dupla
+ Wave Onda
+ Teardrop Lágrima
+ Striped Ribbon Fita Listrada
+ Fine Nib Pena Fina
+ Broad Nib Pena Larga
+ Chisel Cinzel
+ Round Pen Pena Redonda
+ Pressure Pen Pena de Pressão
+ Beads Contas
+ Dash Dot Traço-Ponto
+ Scallops Festões
+ Stars Estrelas
+ Triangles Triângulos
+ Sparkles Brilhos
Save Brush Library… Salvar Biblioteca de Pincéis…
@msg `{_1}` has no brushes to apply `{_1}` não tem pincéis para aplicar
@msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries)
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 9af5f4ceb9..af93b86974 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -5922,7 +5922,9 @@ axis Y Axis Ось Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` применяется к градиентным заливкам (paint.setFill)
@msg `dither` must be true or false `dither` должно быть true или false
Perceptual Перцепционный
-@msg `method` must be classic or perceptual `method` должен быть classic или perceptual
+ Hue Clockwise Оттенок по часовой стрелке
+ Hue Counterclockwise Оттенок против часовой стрелки
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` должен быть classic, perceptual, hue-clockwise или hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` должен быть pad, repeat или reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` должен быть целым числом от 1 до {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` применяется к линейным, радиальным, коническим и ромбовидным градиентам
@@ -6165,6 +6167,7 @@ axis Y Axis Ось Y
Continue Продолжить
Continue Action Продолжить действие
Delete Action Step Удалить шаг действия
+ Move Action Step Переместить шаг действия
Press Play Current Selection in the Actions panel to go on. Чтобы продолжить, нажмите «Воспроизвести текущее выделение» на панели «Действия».
Stop: {message} Остановка: {message}
@msg no action is waiting at a stop ни одно действие не ожидает на остановке
@@ -6336,6 +6339,22 @@ axis Y Axis Ось Y
Graphite Pencil Графитный карандаш
Wax Crayon Восковой мелок
Charcoal Dust Угольная пыль
+ Notched Ribbon Лента с насечками
+ Double Line Двойная линия
+ Wave Волна
+ Teardrop Капля
+ Striped Ribbon Полосатая лента
+ Fine Nib Тонкое перо
+ Broad Nib Широкое перо
+ Chisel Стамеска
+ Round Pen Круглое перо
+ Pressure Pen Перо с нажимом
+ Beads Бусины
+ Dash Dot Штрихпунктир
+ Scallops Фестоны
+ Stars Звёзды
+ Triangles Треугольники
+ Sparkles Искры
Save Brush Library… Сохранить библиотеку кистей…
@msg `{_1}` has no brushes to apply В `{_1}` нет кистей для применения
@msg missing `library` (see brush.libraries) не указан `library` (см. brush.libraries)
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
index 7c9d977648..f26438a201 100644
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -5677,6 +5677,8 @@ lighting Light Ljus
Pen Penna
Pentagram Pentagram
Perceptual Perceptuell
+ Hue Clockwise Nyans medurs
+ Hue Counterclockwise Nyans moturs
Phone 360×800 Telefon 360×800
Phone 375×667 Telefon 375×667
Phone 375×812 Telefon 375×812
@@ -6333,7 +6335,7 @@ axis Y Axis Y-axel
@msg `dither` applies to gradient fills (stroke: false) `dither` gäller gradientfyllningar (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` gäller gradientfyllningar (paint.setFill)
@msg `dither` must be true or false `dither` måste vara true eller false
-@msg `method` must be classic or perceptual `method` måste vara classic eller perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` måste vara classic, perceptual, hue-clockwise eller hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` måste vara pad, repeat eller reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` måste vara ett heltal från 1 till {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` gäller för linjära, radiella och koniska gradienter och rombgradienter
@@ -6535,6 +6537,7 @@ axis Y Axis Y-axel
Continue Fortsätt
Continue Action Fortsätt åtgärd
Delete Action Step Radera åtgärdssteg
+ Move Action Step Flytta å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
@@ -6706,6 +6709,22 @@ axis Y Axis Y-axel
Graphite Pencil Grafitpenna
Wax Crayon Vaxkrita
Charcoal Dust Koldamm
+ Notched Ribbon Hackat band
+ Double Line Dubbellinje
+ Wave Våg
+ Teardrop Droppe
+ Striped Ribbon Randigt band
+ Fine Nib Fin spets
+ Broad Nib Bred spets
+ Chisel Mejsel
+ Round Pen Rund penna
+ Pressure Pen Tryckpenna
+ Beads Pärlor
+ Dash Dot Streck-punkt
+ Scallops Bågkant
+ Stars Stjärnor
+ Triangles Trianglar
+ Sparkles Gnistor
Save Brush Library… Spara penselbibliotek…
@msg `{_1}` has no brushes to apply `{_1}` har inga penslar att använda
@msg missing `library` (see brush.libraries) `library` saknas (se brush.libraries)
diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs
index a18f4d1a58..4ac03011ac 100644
--- a/crates/ui-egui/src/i18n/tests.rs
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -268,6 +268,30 @@ fn built_in_brush_library_names_are_in_complete_catalogs() {
assert!(bad.is_empty(), "{}", bad.join("\n"));
}
+/// A built-in brush library's panel lists its brushes in the UI language, so every brush of every
+/// built-in library has a row in each complete catalog.
+#[test]
+fn built_in_library_brush_names_are_in_complete_catalogs() {
+ use vectorcraft_engine::cmd::brushlib;
+ let session = vectorcraft_engine::Session::new();
+ let mut names: Vec = brushlib::BUILTIN
+ .iter()
+ .filter_map(|b| brushlib::library(&session, b.id))
+ .flat_map(|(_, lib)| lib.brushes.iter().map(|b| b.name.clone()).collect::>())
+ .collect();
+ names.sort();
+ names.dedup();
+ assert_eq!(names.len(), 43, "{names:?}");
+ let mut bad = vec![];
+ for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) {
+ let missing: Vec<&String> = names.iter().filter(|n| lang.catalog().plain(n).is_none()).collect();
+ if !missing.is_empty() {
+ bad.push(format!("{} is missing {missing:?}", lang.code));
+ }
+ }
+ assert!(bad.is_empty(), "{}", bad.join("\n"));
+}
+
/// Preference choices and the marquee's E hint share the same translated labels.
#[test]
fn marquee_selection_labels_are_in_complete_catalogs() {
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index 6a9f8d51fc..a91aab6f0e 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -5470,6 +5470,8 @@ lighting Light Işık
Pen Kalem
Pentagram Pentagram
Perceptual algısal
+ Hue Clockwise Renk tonu saat yönünde
+ Hue Counterclockwise Renk tonu saat yönünün tersine
Phone 360×800 Telefon 360×800
Phone 375×667 Telefon 375×667
Phone 375×812 Telefon 375×812
@@ -6267,7 +6269,7 @@ axis Y Axis Y ekseni
Optical Effects Optik Efektler
Geometric Patterns Geometrik Desenler
Grit Textures Grenli Dokular
-@msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` classic, perceptual, hue-clockwise veya hue-counterclockwise olmalıdır
@msg `spread` must be pad, repeat or reflect `spread` pad, repeat veya reflect olmalıdır
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 ile {MAX_REPEATS} arasında bir tam sayı olmalıdır
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` doğrusal, radyal, konik ve elmas degradeler için geçerlidir
@@ -6510,6 +6512,7 @@ axis Y Axis Y ekseni
Continue Devam Et
Continue Action Eyleme Devam Et
Delete Action Step Eylem Adımını Sil
+ Move Action Step Eylem Adımını Taşı
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
@@ -6681,6 +6684,22 @@ axis Y Axis Y ekseni
Graphite Pencil Grafit Kalem
Wax Crayon Mum Boya
Charcoal Dust Kömür Tozu
+ Notched Ribbon Çentikli Şerit
+ Double Line Çift Çizgi
+ Wave Dalga
+ Teardrop Gözyaşı
+ Striped Ribbon Çizgili Şerit
+ Fine Nib İnce Uç
+ Broad Nib Geniş Uç
+ Chisel Keski
+ Round Pen Yuvarlak Kalem
+ Pressure Pen Basınçlı Kalem
+ Beads Boncuklar
+ Dash Dot Çizgi Nokta
+ Scallops Fistolar
+ Stars Yıldızlar
+ Triangles Üçgenler
+ Sparkles Parıltılar
Save Brush Library… Fırça Kitaplığını Kaydet…
@msg `{_1}` has no brushes to apply `{_1}` içinde uygulanacak fırça yok
@msg missing `library` (see brush.libraries) `library` eksik (bkz. brush.libraries)
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 8681297977..17ddeb2086 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -5917,7 +5917,9 @@ axis Y Axis Вісь Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` застосовується до градієнтних заливок (paint.setFill)
@msg `dither` must be true or false `dither` має бути true або false
Perceptual Перцептивний
-@msg `method` must be classic or perceptual `method` має бути classic або perceptual
+ Hue Clockwise Відтінок за годинниковою стрілкою
+ Hue Counterclockwise Відтінок проти годинникової стрілки
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` має бути classic, perceptual, hue-clockwise або hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` має бути pad, repeat або reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` має бути цілим числом від 1 до {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` застосовується до лінійних, радіальних, конічних і ромбоподібних градієнтів
@@ -6160,6 +6162,7 @@ axis Y Axis Вісь Y
Continue Продовжити
Continue Action Продовжити дію
Delete Action Step Видалити крок дії
+ Move Action Step Перемістити крок дії
Press Play Current Selection in the Actions panel to go on. Щоб продовжити, натисніть «Відтворити поточне виділення» на панелі «Дії».
Stop: {message} Зупинка: {message}
@msg no action is waiting at a stop жодна дія не очікує на зупинці
@@ -6331,6 +6334,22 @@ axis Y Axis Вісь Y
Graphite Pencil Графітний олівець
Wax Crayon Восковий олівець
Charcoal Dust Вугільний пил
+ Notched Ribbon Стрічка з насічками
+ Double Line Подвійна лінія
+ Wave Хвиля
+ Teardrop Крапля
+ Striped Ribbon Смугаста стрічка
+ Fine Nib Тонке перо
+ Broad Nib Широке перо
+ Chisel Стамеска
+ Round Pen Кругле перо
+ Pressure Pen Перо з натиском
+ Beads Намистини
+ Dash Dot Штрихпунктир
+ Scallops Фестони
+ Stars Зірки
+ Triangles Трикутники
+ Sparkles Іскри
Save Brush Library… Зберегти бібліотеку пензлів…
@msg `{_1}` has no brushes to apply У `{_1}` немає пензлів для застосування
@msg missing `library` (see brush.libraries) не вказано `library` (див. brush.libraries)
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index 516ba15672..7d3d89a82d 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -5918,7 +5918,9 @@ axis Y Axis Trục Y
@msg `dither` applies to gradient fills (paint.setFill) `dither` áp dụng cho vùng tô chuyển sắc (paint.setFill)
@msg `dither` must be true or false `dither` phải là true hoặc false
Perceptual Theo cảm nhận
-@msg `method` must be classic or perceptual `method` phải là classic hoặc perceptual
+ Hue Clockwise Sắc độ theo chiều kim đồng hồ
+ Hue Counterclockwise Sắc độ ngược chiều kim đồng hồ
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` phải là classic, perceptual, hue-clockwise hoặc hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` phải là pad, repeat hoặc reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` phải là số nguyên từ 1 đến {MAX_REPEATS}
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` áp dụng cho chuyển sắc tuyến tính, tỏa tròn, hình nón và hình thoi
@@ -6161,6 +6163,7 @@ axis Y Axis Trục Y
Continue Tiếp tục
Continue Action Tiếp tục hành động
Delete Action Step Xóa bước hành động
+ Move Action Step Di chuyển 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
@@ -6332,6 +6335,22 @@ axis Y Axis Trục Y
Graphite Pencil Bút chì than chì
Wax Crayon Bút sáp màu
Charcoal Dust Bụi than
+ Notched Ribbon Dải băng khía
+ Double Line Đường đôi
+ Wave Sóng
+ Teardrop Giọt lệ
+ Striped Ribbon Dải băng sọc
+ Fine Nib Ngòi mảnh
+ Broad Nib Ngòi rộng
+ Chisel Đục
+ Round Pen Bút tròn
+ Pressure Pen Bút áp lực
+ Beads Hạt cườm
+ Dash Dot Gạch chấm
+ Scallops Viền vỏ sò
+ Stars Ngôi sao
+ Triangles Tam giác
+ Sparkles Lấp lánh
Save Brush Library… Lưu thư viện cọ…
@msg `{_1}` has no brushes to apply `{_1}` không có cọ nào để áp dụng
@msg missing `library` (see brush.libraries) thiếu `library` (xem brush.libraries)
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 35d92d6434..34ffeb5d6b 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -5266,6 +5266,8 @@ imageTrace Outline 轮廓
Outlined Text 轮廓化的文字
All Rasterized Regions 所有栅格化区域
Perceptual 可感知
+ Hue Clockwise 色相顺时针
+ Hue Counterclockwise 色相逆时针
Relative Colorimetric 相对比色
Absolute Colorimetric 绝对比色
Simulate 模拟
@@ -6150,7 +6152,7 @@ axis Y Axis Y 轴
@msg `dither` applies to gradient fills (stroke: false) `dither` 适用于渐变填色 (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` 适用于渐变填色 (paint.setFill)
@msg `dither` must be true or false `dither` 必须是 true 或 false
-@msg `method` must be classic or perceptual `method` 必须是 classic 或 perceptual
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` 必须是 classic、perceptual、hue-clockwise 或 hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` 必须是 pad、repeat 或 reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必须是 1 到 {MAX_REPEATS} 之间的整数
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` 适用于线性、径向、圆锥和菱形渐变
@@ -6393,6 +6395,7 @@ axis Y Axis Y 轴
Continue 继续
Continue Action 继续动作
Delete Action Step 删除动作步骤
+ Move Action Step 移动动作步骤
Press Play Current Selection in the Actions panel to go on. 要继续,请按“动作”面板中的“播放当前选择项目”。
Stop: {message} 停止:{message}
@msg no action is waiting at a stop 没有动作在停止处等待
@@ -6564,6 +6567,22 @@ axis Y Axis Y 轴
Graphite Pencil 石墨铅笔
Wax Crayon 蜡笔
Charcoal Dust 炭粉
+ Notched Ribbon 凹口丝带
+ Double Line 双线
+ Wave 波浪
+ Teardrop 泪滴
+ Striped Ribbon 条纹丝带
+ Fine Nib 细笔尖
+ Broad Nib 宽笔尖
+ Chisel 凿形
+ Round Pen 圆头笔
+ Pressure Pen 压感笔
+ Beads 珠串
+ Dash Dot 点划线
+ Scallops 扇贝边
+ Stars 星形
+ Triangles 三角形
+ Sparkles 闪光
Save Brush Library… 保存画笔库…
@msg `{_1}` has no brushes to apply `{_1}` 中没有可应用的画笔
@msg missing `library` (see brush.libraries) 缺少 `library`(请参阅 brush.libraries)
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index dafc57aa29..309e00346c 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -5956,7 +5956,9 @@ axis Y Axis Y 軸
@msg `dither` applies to gradient fills (paint.setFill) `dither` 適用於漸層填色 (paint.setFill)
@msg `dither` must be true or false `dither` 必須是 true 或 false
Perceptual 感知
-@msg `method` must be classic or perceptual `method` 必須是 classic 或 perceptual
+ Hue Clockwise 色相順時針
+ Hue Counterclockwise 色相逆時針
+@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` 必須是 classic、perceptual、hue-clockwise 或 hue-counterclockwise
@msg `spread` must be pad, repeat or reflect `spread` 必須是 pad、repeat 或 reflect
@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必須是 1 到 {MAX_REPEATS} 之間的整數
@msg `spread` applies to linear, radial, conic and diamond gradients `spread` 適用於線性、放射狀、圓錐和菱形漸層
@@ -6199,6 +6201,7 @@ axis Y Axis Y 軸
Continue 繼續
Continue Action 繼續動作
Delete Action Step 刪除動作步驟
+ Move Action Step 移動動作步驟
Press Play Current Selection in the Actions panel to go on. 若要繼續,請按「動作」面板中的「播放目前選取項目」。
Stop: {message} 停止:{message}
@msg no action is waiting at a stop 沒有動作在停止處等待
@@ -6370,6 +6373,22 @@ axis Y Axis Y 軸
Graphite Pencil 石墨鉛筆
Wax Crayon 蠟筆
Charcoal Dust 炭粉
+ Notched Ribbon 凹口緞帶
+ Double Line 雙線
+ Wave 波浪
+ Teardrop 淚滴
+ Striped Ribbon 條紋緞帶
+ Fine Nib 細筆尖
+ Broad Nib 寬筆尖
+ Chisel 鑿形
+ Round Pen 圓頭筆
+ Pressure Pen 壓感筆
+ Beads 珠串
+ Dash Dot 點劃線
+ Scallops 扇貝邊
+ Stars 星形
+ Triangles 三角形
+ Sparkles 閃光
Save Brush Library… 儲存筆刷資料庫…
@msg `{_1}` has no brushes to apply `{_1}` 中沒有可套用的筆刷
@msg missing `library` (see brush.libraries) 未提供 `library`(請參閱 brush.libraries)
diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs
index 5050529039..aaf2bd5b1f 100644
--- a/crates/ui-egui/src/io/export.rs
+++ b/crates/ui-egui/src/io/export.rs
@@ -28,7 +28,8 @@ pub fn hide_exports(app: &mut VectorcraftApp) -> Value {
/// `document.export` options in `params` (artboard, range, useArtboards, selectedOnly, ppi, SVG
/// options…): every file it writes (one per artboard, linked images) goes next to `path`. The
/// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are
-/// several).
+/// several). When automation asked for the export, `warnings` also name each option the written
+/// format doesn't read, as `document.export`'s do.
pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result {
// Options given as anything but an object are none: the path and format still reach the
// encoder (an exporter plug-in is told the file it writes).
@@ -62,6 +63,10 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option z,
_ => f,
};
+ // An export that automation asked for, also through a save panel it opened, names the options
+ // the file doesn't read, as `document.export` does. A person's export gets no such notes.
+ let unused = if app.automation_asked() { fileio::unused_option_notes("document.export", f, params) } else { Vec::new() };
+ let noted = unused.clone();
// Background Export: encoded and written from this snapshot while editing goes on. The
// encoder is told the file it writes (an exporter plug-in gets its name and extension).
let label = format!("Exporting {}", fileio::file_name(&path));
@@ -93,7 +98,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option 1 {
out["files"] = json!(files);
}
@@ -132,6 +143,11 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option Option crate::panels::actions::insert_select_object(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),
+ crate::panels::actions::MOVE_STEP => crate::panels::actions::move_step(app, p),
"ui.batchDialog" => {
let index = |k: &str| opt_index(p, k).map(|i| i.unwrap_or(0));
index("set").and_then(|set| Ok((set, index("action")?))).and_then(|(set, action)| crate::dialogs::action_batch::open(app, set, action))
diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs
index 5f6da1ca36..37cdcb2d56 100644
--- a/crates/ui-egui/src/menus/ui_commands.rs
+++ b/crates/ui-egui/src/menus/ui_commands.rs
@@ -1478,13 +1478,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
crate::panels::actions::START_RECORDING,
"Begin Recording",
"",
- "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}",
+ "{set?: index, action?: index, after?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. With action, record into the action at action in set instead: ui.action.stop puts the recorded steps after step after of that action, else after its last step, and the steps after them keep whether they are excluded. The panel's Begin Recording button passes the selected action and its selected step. action without set, after without action, an index past the last set, action or step, an explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}, or with action {set, action, name}",
),
(
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, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. 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 or the action ui.action.record named, at the place it named, 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. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}",
),
(
crate::panels::actions::INSERT_STOP,
@@ -1522,6 +1522,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{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}",
),
+ (
+ crate::panels::actions::MOVE_STEP,
+ "Move Action Step",
+ "",
+ "{set: index, action: index, step: index, toSet?: index, toAction?: index, before: index} move 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) into the action at toAction in toSet (default: the same set and action), before the step at index before there, or after its last step when before is its number of steps; before counts the steps as they are before the move. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays selected, and a moved selected step makes its new action the selected one and its set the current one. A Record Stop dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay after the steps that were before them. A step put back just before or just after itself does not move. A missing or invalid index, or a move that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. Undo does not move the step back. Dragging a step row in the panel runs this → {set, action, step, moved}",
+ ),
(
"ui.batchDialog",
"Batch…",
diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs
index 4f42eea008..1971834a07 100644
--- a/crates/ui-egui/src/panels/actions.rs
+++ b/crates/ui-egui/src/panels/actions.rs
@@ -894,16 +894,27 @@ pub const STOP_RECORDING: &str = "ui.action.stop";
/// The command that begins a plain recording ([`record`]).
pub const START_RECORDING: &str = "ui.action.record";
-/// `ui.action.record {set?}`: begins recording an automatically named Action N in `set`, or in
-/// the current set (the first if none is current). With no sets, makes Set 1. Stop adds the action
-/// after the set's existing actions. An explicit invalid set, an existing recording, or a set whose
-/// new action would pass a limit of Load Actions… ([`edit_keeps_set_loadable`]: 1,000 actions,
-/// 8 MiB) is an error and leaves the panel unchanged. While it records, a command whose step does not fit in the action
-/// ([`recording_room`]) does not run. → {set, name}
+/// `ui.action.record {set?, action?, after?}`: begins recording an automatically named Action N in
+/// `set`, or in the current set (the first if none is current). With no sets, makes Set 1. Stop
+/// adds the action after the set's existing actions. With `action`, it records into the action at
+/// that index of set `set` instead: Stop puts the recorded steps after step `after` of that action,
+/// else after its last step, and the steps after them keep whether they are excluded. The Begin
+/// Recording button passes the selected action and step. `action` without `set`, `after` without
+/// `action`, an index past the last set, action or step, an explicit invalid set, an existing
+/// recording, or a set whose new action would pass a limit of Load Actions…
+/// ([`edit_keeps_set_loadable`]: 1,000 actions, 8 MiB) is an error and leaves the panel unchanged.
+/// While it records, a command whose step does not fit in the action ([`recording_room`]) does not
+/// run. → {set, name}, and with `action` {set, action, name}
pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result {
if app.ui.recording.is_some() {
return Err("an action is already recording".into());
}
+ if p.get("action").is_some() {
+ return record_into(app, p);
+ }
+ if p.get("after").is_some() {
+ return Err("no such action".into());
+ }
let set = match p.get("set") {
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < app.ui.action_sets.len()).ok_or("no such action set")?,
_ => {
@@ -931,8 +942,32 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result {
Ok(json!({ "set": set, "name": name }))
}
+/// [`record`] with `action`: records into the action at index `action` of set `set`, after step
+/// `after` or after its last step. The set becomes current and the only selected set, and the
+/// action stays selected. → {set, action, name}
+fn record_into(app: &mut VectorcraftApp, p: &Value) -> Result {
+ let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok());
+ let (set, at) =
+ index("set").zip(index("action")).filter(|&(s, a)| app.ui.action_sets.get(s).is_some_and(|s| a < s.actions.len())).ok_or("no such action")?;
+ let action = &app.ui.action_sets[set].actions[at];
+ let slot = match p.get("after") {
+ None => None,
+ Some(_) => Some(index("after").filter(|&s| s < action.steps.len()).ok_or("no such action step")? + 1),
+ };
+ let (id, name) = (action.id, action.name.clone());
+ app.ui.action_set = Some(set);
+ app.ui.action_sets_selected = vec![set];
+ start_recording(app, set, name.clone(), Some(id));
+ app.ui.recording_at = slot;
+ if let Some(ctx) = app.ui_ctx.clone() {
+ set_pstate::>(&ctx, "actions-selected", Some((set, at)));
+ }
+ Ok(json!({ "set": set, "action": at, "name": name }))
+}
+
/// `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
+/// Action made for it or the action [`record`] named, at the place it named, 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, 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. Recorded steps past the limits of an
@@ -951,6 +986,13 @@ 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";
+/// The panel state that holds the step being dragged as a [`DraggedStep`].
+const DRAGGED_STEP: &str = "actions-dragged-step";
+
+/// A step being dragged: the action's [`Action::id`], the step's index, and the action's steps and
+/// excluded steps as they were when the drag started.
+type DraggedStep = (u64, usize, std::sync::Arc<(Vec<(String, Value)>, BTreeSet)>);
+
/// `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
@@ -1083,9 +1125,9 @@ fn recording_start(app: &VectorcraftApp) -> Option {
}
/// Where Stop Playing/Recording puts the steps of the recording in progress: the index of the set,
-/// and of the action New Action made for it, or None for the new action Stop adds after the set's
-/// last action. None when there is no recording, no set to put them in, or the action New Action
-/// made for it was deleted, so that Stop keeps none of them.
+/// and of the action it records into ([`crate::UiState::recording_into`]), or None for the new
+/// action Stop adds after the set's last action. None when there is no recording, no set to put
+/// them in, or the action it records into was deleted, so that Stop keeps none of them.
fn recording_target(app: &VectorcraftApp) -> Option<(usize, Option)> {
let (set, ..) = app.ui.recording.as_ref()?;
let Some(id) = app.ui.recording_into else {
@@ -1107,7 +1149,7 @@ fn set_with_recorded(app: &VectorcraftApp, steps: Vec<(String, Value)>) -> Optio
s.actions.len() - 1
}
};
- extend_recorded(s.actions.get_mut(at)?, steps);
+ put_recorded(s.actions.get_mut(at)?, steps, app.ui.recording_at);
Some((s, at))
}
@@ -1144,7 +1186,7 @@ fn set_sizes(app: &VectorcraftApp, at: Option, set: &ActionSet) -> [usize
let mut s = set.clone();
let steps = app.session.journal_for_action(start);
match app.ui.recording_into.and_then(|id| s.actions.iter().position(|a| a.id == id)) {
- Some(i) => extend_recorded(&mut s.actions[i], steps),
+ Some(i) => put_recorded(&mut s.actions[i], steps, app.ui.recording_at),
None => s.actions.push(Action::new(name, steps)),
}
with = s;
@@ -1359,12 +1401,17 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result<
let excluded = action.excluded_steps.clone();
action.insert_step(after, step);
let id = action.id;
+ // The recording's steps stay after the steps that were before them.
+ let slot = match (&recorded, app.ui.recording_at) {
+ (Some(r), Some(slot)) if r.into == Some(at) && after <= slot => Some(slot + 1),
+ (_, slot) => slot,
+ };
let file_len = match recorded {
None => files::write_set(s).len(),
Some(Recorded { into, name, steps }) => {
let mut kept = s.clone();
match into {
- Some(into) => kept.actions[into].steps.extend(steps),
+ Some(into) => put_recorded(&mut kept.actions[into], steps, slot),
None => kept.actions.push(Action::new(name, steps)),
}
files::write_set(&kept).len()
@@ -1376,6 +1423,7 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result<
action.excluded_steps = excluded;
return Err("the action set would be larger than 8 MiB".into());
}
+ app.ui.recording_at = slot;
app.ui.action_set = Some(set);
app.ui.action_sets_selected = vec![set];
if let Some(ctx) = app.ui_ctx.clone() {
@@ -1415,6 +1463,12 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result
let step = index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")?;
action.remove_step(step);
let id = action.id;
+ // The recording's steps stay after the steps that were before them.
+ if app.ui.recording_into == Some(id)
+ && let Some(slot) = app.ui.recording_at.filter(|&slot| step < slot)
+ {
+ app.ui.recording_at = Some(slot - 1);
+ }
if let Some(ctx) = app.ui_ctx.clone()
&& pstate::>(&ctx, SELECTED_STEP).is_some_and(|(selected, _)| selected == id)
{
@@ -1437,6 +1491,115 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result
Ok(json!({"set": set, "action": at, "step": step}))
}
+/// The command that moves one step of an action ([`move_step`]).
+pub const MOVE_STEP: &str = "ui.action.moveStep";
+
+/// `ui.action.moveStep {set, action, step, toSet?, toAction?, before}`: moves step `step`, a stop or
+/// a command, of the action at index `action` of set `set` into the action at index `toAction` of
+/// set `toSet` (default: the same set and action), before the step at index `before` there, or
+/// after its last step when `before` is its number of steps. `before` counts the steps as they are
+/// before the move. The step keeps its params and whether it is excluded, and the other steps keep
+/// theirs. A selected step stays selected: a moved selected step makes its new action the selected
+/// one and its set the current one. A Record Stop, Insert Menu Item or Set Selection dialog open on
+/// a step follows the step, and the steps a recording adds to either action stay after the steps
+/// that were before them. A step put back just before or just after itself does not move. A missing
+/// or invalid index, or a move that would give a set a file that Load Actions… refuses
+/// ([`edit_keeps_set_loadable`]: 10,000 steps in an action, 8 MiB), is an error, and then nothing
+/// changes. Undo does not move the step back. A drag of a step row in the panel runs this.
+/// → {set, action, step, moved}: where the step is now
+pub fn move_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 source = app.ui.action_sets.get(set).and_then(|s| s.actions.get(at)).ok_or("no such action")?;
+ let step = index("step").filter(|&s| s < source.steps.len()).ok_or("no such action step")?;
+ let source_id = source.id;
+ let to_set = match p.get("toSet") {
+ None => set,
+ Some(v) => number(v).filter(|&s| s < app.ui.action_sets.len()).ok_or("no such action set")?,
+ };
+ let to_action = match p.get("toAction") {
+ None => at,
+ Some(v) => number(v).ok_or("no such action")?,
+ };
+ let dest = app.ui.action_sets[to_set].actions.get(to_action).ok_or("no such action")?;
+ let dest_id = dest.id;
+ let before = index("before").filter(|&b| b <= dest.steps.len()).ok_or("no such action step")?;
+ let same = (set, at) == (to_set, to_action);
+ if same && (before == step || before == step + 1) {
+ return Ok(json!({"set": set, "action": at, "step": step, "moved": false}));
+ }
+ // The step's index once it is in place, counted without it in its own action.
+ let to = if same && before > step { before - 1 } else { before };
+ let mut from_edit = app.ui.action_sets[set].clone();
+ let moved = from_edit.actions[at].steps[step].clone();
+ let excluded = from_edit.actions[at].excluded_steps.contains(&step);
+ from_edit.actions[at].remove_step(step);
+ let put = |a: &mut Action| {
+ a.insert_step(to, moved);
+ if excluded {
+ a.excluded_steps.insert(to);
+ }
+ };
+ let to_edit = if to_set == set {
+ put(&mut from_edit.actions[to_action]);
+ None
+ } else {
+ let mut edit = app.ui.action_sets[to_set].clone();
+ put(&mut edit.actions[to_action]);
+ Some(edit)
+ };
+ edit_keeps_set_loadable(app, Some(set), &from_edit)?;
+ if let Some(edit) = &to_edit {
+ edit_keeps_set_loadable(app, Some(to_set), edit)?;
+ }
+ app.ui.action_sets[set] = from_edit;
+ if let Some(edit) = to_edit {
+ app.ui.action_sets[to_set] = edit;
+ }
+ // A step index in `source_id` or `dest_id` as (the action's id, the step) after the move.
+ let follow = |(id, s): (u64, usize)| -> (u64, usize) {
+ if (id, s) == (source_id, step) {
+ return (dest_id, to);
+ }
+ let s = if id == source_id && s > step { s - 1 } else { s };
+ (id, if id == dest_id && s >= to { s + 1 } else { s })
+ };
+ // The recording's steps stay after the steps that were before them.
+ if let Some(slot) = app.ui.recording_at {
+ let slot = if app.ui.recording_into == Some(source_id) && step < slot { slot - 1 } else { slot };
+ app.ui.recording_at = Some(if app.ui.recording_into == Some(dest_id) && to <= slot { slot + 1 } else { slot });
+ }
+ if let Some(ctx) = app.ui_ctx.clone()
+ && let Some(selected) = pstate::>(&ctx, SELECTED_STEP)
+ {
+ set_pstate:: >(&ctx, SELECTED_STEP, Some(follow(selected)));
+ if selected == (source_id, step) {
+ set_pstate:: >(&ctx, "actions-selected", Some((to_set, to_action)));
+ let mut expanded: BTreeSet = pstate(&ctx, "actions-expanded");
+ expanded.insert(dest_id);
+ set_pstate(&ctx, "actions-expanded", expanded);
+ app.ui.action_set = Some(to_set);
+ app.ui.action_sets_selected = vec![to_set];
+ }
+ }
+ // A Record Stop, Insert Menu Item or Set Selection dialog open on a step of either action follows it.
+ let either = |s: usize, a: usize| (s, a) == (set, at) || (s, a) == (to_set, to_action);
+ if let Some(d) = app.ui.dialog.as_mut().filter(|d| holds_step(d))
+ && let (Some(ds), Some(da), Some(ds_step)) =
+ (d.fields.get("set").and_then(number), d.fields.get("action").and_then(number), d.fields.get("step").and_then(number))
+ && either(ds, da)
+ {
+ let id = if (ds, da) == (set, at) { source_id } else { dest_id };
+ let (id, s) = follow((id, ds_step));
+ let (ds, da) = if id == dest_id { (to_set, to_action) } else { (set, at) };
+ d.fields.insert("set".into(), json!(ds));
+ d.fields.insert("action".into(), json!(da));
+ d.fields.insert("step".into(), json!(s));
+ }
+ Ok(json!({"set": to_set, "action": to_action, "step": to, "moved": true}))
+}
+
/// 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 {
@@ -1471,6 +1634,7 @@ fn start_recording(app: &mut VectorcraftApp, set: usize, name: String, into: Opt
app.session.journal.hold(Some(start));
app.ui.recording = Some((set, name, start));
app.ui.recording_into = into;
+ app.ui.recording_at = None;
app.ui.recording_base = None;
app.ui.recording_tail = None;
app.ui.recording_checked = Some((app.session.journal.rewrites(), start));
@@ -1498,12 +1662,15 @@ fn steps_that_fit(app: &VectorcraftApp, steps: &[(String, Value)]) -> (usize, Op
(keep, (keep < steps.len()).then_some("the action already has 10,000 steps"))
}
-/// Adds the recorded `steps` after the steps of `action`, included in playback. An exclusion past
-/// the action's last step, which saved preferences can hold, goes.
-fn extend_recorded(action: &mut Action, steps: Vec<(String, Value)>) {
+/// Puts the recorded `steps` into `action` before its step at index `at`, or after its last step
+/// when `at` is None or past it, included in playback. The steps after them keep whether they are
+/// excluded. An exclusion past the action's last step, which saved preferences can hold, goes.
+fn put_recorded(action: &mut Action, steps: Vec<(String, Value)>, at: Option) {
let len = action.steps.len();
- action.excluded_steps.retain(|&i| i < len);
- action.steps.extend(steps);
+ let at = at.map_or(len, |at| at.min(len));
+ let added = steps.len();
+ action.excluded_steps = action.excluded_steps.iter().filter(|&&i| i < len).map(|&i| if i >= at { i + added } else { i }).collect();
+ action.steps.splice(at..at, steps);
}
/// Stop Playing/Recording: ends the recording and keeps its steps (see [`start_recording`]). An
@@ -1522,10 +1689,16 @@ fn stop_recording(app: &mut VectorcraftApp) {
app.ui.recording_tail = None;
app.ui.recording_checked = None;
app.session.journal.hold(None);
+ let at = app.ui.recording_at.take();
match app.ui.recording_into.take() {
Some(id) => {
- if let Some(a) = app.ui.action_sets.iter_mut().flat_map(|s| s.actions.iter_mut()).find(|a| a.id == id) {
- extend_recorded(a, steps);
+ let found = app.ui.action_sets.iter().enumerate().find_map(|(s, set)| Some((s, set.actions.iter().position(|a| a.id == id)?)));
+ if let Some((s, a)) = found {
+ let action = &mut app.ui.action_sets[s].actions[a];
+ let len = action.steps.len();
+ let (from, added) = (at.map_or(len, |at| at.min(len)), steps.len());
+ put_recorded(action, steps, at);
+ follow_recorded(app, (s, a, id), from, added);
}
}
None => {
@@ -1536,6 +1709,28 @@ fn stop_recording(app: &mut VectorcraftApp) {
}
}
+/// After Stop Playing/Recording put `added` steps into the action at `place` (set, action, id)
+/// before its step at index `from`: the selected step and a Record Stop, Insert Menu Item or Set
+/// Selection dialog open on a later step of the action follow their step down.
+fn follow_recorded(app: &mut VectorcraftApp, (set, at, id): (usize, usize, u64), from: usize, added: usize) {
+ if added == 0 {
+ return;
+ }
+ if let Some(ctx) = app.ui_ctx.clone()
+ && let Some((step_id, step)) = pstate::>(&ctx, SELECTED_STEP).filter(|&(step_id, step)| step_id == id && step >= from)
+ {
+ set_pstate:: >(&ctx, SELECTED_STEP, Some((step_id, step + added)));
+ }
+ let number = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok());
+ if let Some(d) = app.ui.dialog.as_mut().filter(|d| holds_step(d))
+ && d.fields.get("set").and_then(number) == Some(set)
+ && d.fields.get("action").and_then(number) == Some(at)
+ && let Some(step) = d.fields.get("step").and_then(number).filter(|&step| step >= from)
+ {
+ d.fields.insert("step".into(), json!(step + added));
+ }
+}
+
crate::shortcut_editor::ui_mirror!(fn function_key_chords() -> Vec);
/// Mirrors the actions' function keys for code without app access (cheap when unchanged).
@@ -1673,6 +1868,7 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result
if sets.contains(rec) {
app.ui.recording = None;
app.ui.recording_into = None;
+ app.ui.recording_at = None;
app.session.journal.hold(None);
} else {
*rec -= sets.iter().filter(|&&s| s < *rec).count();
@@ -1926,6 +2122,34 @@ fn inclusion_box(
(response.on_hover_text(if included { tl!("Exclude from playback") } else { tl!("Include in playback") }).clicked(), hit)
}
+/// While a step is dragged over the list: where it goes as (set, action, `before` of [`MOVE_STEP`]),
+/// shown by a line in `color` on the row under the pointer, or None off every action's rows
+/// (`slots`, as [`show`] lists them). Near the list's top or bottom edge, the list scrolls.
+fn drop_target(ui: &mut egui::Ui, slots: &[(egui::Rect, usize, usize, usize, usize)], color: egui::Color32) -> Option<(usize, usize, usize)> {
+ let p = ui.ctx().pointer_interact_pos()?;
+ let clip = ui.clip_rect();
+ if !clip.contains(p) {
+ return None;
+ }
+ let edge = 12.0;
+ let scroll = if p.y < clip.top() + edge {
+ 6.0
+ } else if p.y > clip.bottom() - edge {
+ -6.0
+ } else {
+ 0.0
+ };
+ if scroll != 0.0 && ui.input(|i| i.pointer.any_down()) {
+ ui.scroll_with_delta(egui::vec2(0.0, scroll));
+ ui.ctx().request_repaint();
+ }
+ let half_gap = ui.spacing().item_spacing.y / 2.0;
+ let &(r, si, ai, upper, lower) = slots.iter().find(|(r, ..)| (r.top() - half_gap..r.bottom() + half_gap).contains(&p.y))?;
+ let (before, y) = if upper != lower && p.y < r.center().y { (upper, r.top()) } else { (lower, r.bottom()) };
+ ui.painter().line_segment([egui::pos2(r.left() + 42.0, y), egui::pos2(r.right(), y)], egui::Stroke::new(2.0, color));
+ Some((si, ai, before))
+}
+
enum InclusionChange {
Set(usize),
Action(usize, usize),
@@ -1954,6 +2178,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let mut expanded: BTreeSet = pstate(ui.ctx(), "actions-expanded");
let mut inclusion_changes = Vec::new();
let mut play_req = None;
+ // The step being dragged, from the frame its drag started. The drag ends without a move when a
+ // command since then changed its action's steps or which of them are excluded, or deleted the
+ // action.
+ let dragging = pstate::