diff --git a/Cargo.lock b/Cargo.lock
index 0c34a6a952..dc08d4542e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6341,7 +6341,6 @@ dependencies = [
"raw-window-handle",
"rfd",
"serde_json",
- "smithay-clipboard",
"socket2",
"tempfile",
"vectorcraft-engine",
@@ -6666,6 +6665,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..2da5f0083d 100644
--- a/apps/vectorcraft/Cargo.toml
+++ b/apps/vectorcraft/Cargo.toml
@@ -58,7 +58,10 @@ pollster = "1"
# cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which
# src/caps_lock.rs uses to read whether Caps Lock is on. Win32_System_Registry has `RegGetValueW`,
# which src/host_services.rs uses to read the path PhotoCraft registers under App Paths.
-windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
+# Win32_Devices_Display has `QueryDisplayConfig`, Win32_UI_ColorSystem the display's ICC profile and
+# Win32_System_LibraryLoader `GetProcAddress`, which src/display_profile.rs uses to read the
+# display's color profile for the canvas.
+windows = { version = "0.62", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_ColorSystem", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
# The renderer's worker count follows those cores (src/windows_cores.rs).
vectorcraft-render = { workspace = true }
@@ -93,29 +96,30 @@ winit = { version = "0.30", default-features = false, features = ["wayland-csd-a
# A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a
# second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build
# through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs).
-x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] }
+# RandR reads a display's `_ICC_PROFILE` for the canvas (src/display_profile.rs).
+x11rb = { version = "0.13", default-features = false, features = ["randr", "xinput", "xkb"] }
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
-# so Automatic draws on that one. Both are already in the build through winit.
+# so Automatic draws on that one, and copies and pastes text, SVG and PNG through the compositor's
+# core data-device protocol on the window's connection (src/clipboard.rs), where arboard has neither
+# data-control (GNOME) nor X11 (a Flatpak on a Wayland session). Both are already in the build
+# through winit.
wayland-client = "0.31"
-# Copy and Paste of text through the compositor's core data-device protocol on the window's
-# connection (src/clipboard.rs), where arboard has neither data-control (GNOME) nor X11 (a Flatpak
-# on a Wayland session). MIT, already in the build through egui-winit.
-smithay-clipboard = "0.7"
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
# 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/app.rs b/apps/vectorcraft/src/app.rs
index 4827772444..126b671877 100644
--- a/apps/vectorcraft/src/app.rs
+++ b/apps/vectorcraft/src/app.rs
@@ -219,6 +219,9 @@ impl App {
self.app.services.dock_monitors = window::dock_monitors(w, zoom);
self.monitors_listed = Some((now, zoom));
}
+ if let Some(w) = &self.winit_window {
+ self.display_profile.tick(w, now, self.app.session.prefs.monitor_profile_display, &self.app.display_profile, ctx);
+ }
self.app.tick(ctx);
if self.pending_maximize {
if let Some(w) = &self.winit_window {
diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs
index 1daccc22f1..77a96442c4 100644
--- a/apps/vectorcraft/src/clipboard.rs
+++ b/apps/vectorcraft/src/clipboard.rs
@@ -10,8 +10,9 @@
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
//! copied), falling back to X11 where the compositor lacks the protocol, and where there is no
-//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection,
-//! text only. A clipboard that can't be opened is tried again after a wait that grows to a
+//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection:
+//! Copy offers SVG (`image/svg+xml`), PNG (`image/png`) and text together, and Paste reads SVG,
+//! text and PNG. A clipboard that can't be opened is tried again after a wait that grows to a
//! minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app
//! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at
//! once; a paste then waits for that read's answer off the UI thread.
@@ -24,8 +25,8 @@ use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
use vectorcraft_ui_egui::{ClipboardRead, SystemClipboard};
-/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where
-/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
+/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text, SVG and
+/// PNG where arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
pub fn system_clipboard(window: Option>) -> Box {
#[cfg(windows)]
let cb = {
@@ -629,6 +630,10 @@ mod portable {
enum Ours {
Text(String),
Image(usize, usize),
+ /// A Copy through the Wayland core protocol, whose data source tells whether the
+ /// clipboard still holds it.
+ #[cfg(target_os = "linux")]
+ Source,
}
/// A read of arboard's clipboard.
@@ -858,18 +863,14 @@ mod portable {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
impl Late {
/// What a paste of one of `mimes` takes once the answer comes ([`Unanswered::paste`]). The
- /// core protocol's text is waited for as long, until [`IMAGE_WAIT`] after its read was
- /// sent, then read again, and pasted unless it is empty, what `ours` put on the clipboard,
- /// or doesn't come.
+ /// core protocol's answer is waited for as long, until [`IMAGE_WAIT`] after its read was
+ /// sent; then the clipboard is read again and pasted as at once, unless it holds the Copy
+ /// made here ([`super::wayland::Unanswered::paste`]).
fn paste(self, ours: Option<&Ours>, mimes: &[&'static str]) -> Option {
match self {
Self::Arboard(unanswered) => unanswered.paste(ours, mimes),
#[cfg(target_os = "linux")]
- Self::Wayland(unanswered) => {
- let text = unanswered.text(IMAGE_WAIT).ok().filter(|t| !t.is_empty())?;
- let held = matches!(ours, Some(Ours::Text(t)) if *t == text);
- (!held).then(|| Flavour { mime: TEXT, data: text.into_bytes() })
- }
+ Self::Wayland(unanswered) => unanswered.paste(mimes, IMAGE_WAIT),
}
}
}
@@ -962,14 +963,15 @@ mod portable {
/// An open clipboard connection.
enum Backend {
Arboard(Arboard),
- /// The compositor's core data-device protocol on the window's connection (text only).
+ /// The compositor's core data-device protocol on the window's connection.
#[cfg(target_os = "linux")]
- Wayland(super::wayland::TextClipboard),
+ Wayland(super::wayland::DataClipboard),
}
- /// What a write of a picture says where the clipboard carries text only.
+ /// What a bitmap read or write says on the core protocol, which carries pictures as PNG data
+ /// only ([`Clipboard::write`], [`Clipboard::read_now_or_later`]).
#[cfg(target_os = "linux")]
- const TEXT_ONLY: &str = "this clipboard carries text only";
+ const PNG_ONLY: &str = "this clipboard carries pictures as PNG data";
impl Backend {
/// arboard (Wayland data-control, else X11), else the core protocol on `window`'s
@@ -979,7 +981,10 @@ mod portable {
|| Arboard::open().map(Self::Arboard),
|| {
#[cfg(target_os = "linux")]
- return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window");
+ return window
+ .and_then(|w| super::wayland::DataClipboard::new(w.clone()))
+ .map(Self::Wayland)
+ .ok_or("not a Wayland window with a seat and a data-device manager");
#[cfg(not(target_os = "linux"))]
{
let _ = window;
@@ -993,7 +998,7 @@ mod portable {
match self {
Self::Arboard(_) => "arboard",
#[cfg(target_os = "linux")]
- Self::Wayland(_) => "the Wayland core protocol (text only)",
+ Self::Wayland(_) => "the Wayland core protocol",
}
}
@@ -1001,7 +1006,7 @@ mod portable {
match self {
Self::Arboard(cb) => cb.read(Request::Text).text(),
#[cfg(target_os = "linux")]
- Self::Wayland(cb) => cb.get_text(),
+ Self::Wayland(cb) => cb.text(),
}
}
@@ -1009,10 +1014,17 @@ mod portable {
match self {
Self::Arboard(cb) => cb.cb.set_text(text).map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
- Self::Wayland(cb) => {
- cb.set_text(text);
- Ok(())
- }
+ Self::Wayland(cb) => cb.copy(&[Flavour { mime: TEXT, data: text.as_bytes().to_vec() }]),
+ }
+ }
+
+ /// Whether the clipboard holds the core protocol's last Copy made here (never for arboard,
+ /// whose writes are checked by what they wrote).
+ #[cfg(target_os = "linux")]
+ fn holds_source(&mut self) -> bool {
+ match self {
+ Self::Arboard(_) => false,
+ Self::Wayland(cb) => cb.holds_ours(),
}
}
@@ -1025,7 +1037,7 @@ mod portable {
#[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))]
Self::Arboard(cb) => cb.read(Request::Image).image(),
#[cfg(target_os = "linux")]
- Self::Wayland(_) => Err(TEXT_ONLY.into()),
+ Self::Wayland(_) => Err(PNG_ONLY.into()),
}
}
@@ -1041,7 +1053,7 @@ mod portable {
Ok(Box::new(move || Ok(image)))
}
#[cfg(target_os = "linux")]
- Self::Wayland(_) => Err(TEXT_ONLY.into()),
+ Self::Wayland(_) => Err(PNG_ONLY.into()),
}
}
@@ -1049,7 +1061,7 @@ mod portable {
match self {
Self::Arboard(cb) => cb.cb.set_image(img).map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
- Self::Wayland(_) => Err(TEXT_ONLY.into()),
+ Self::Wayland(_) => Err(PNG_ONLY.into()),
}
}
@@ -1057,15 +1069,12 @@ mod portable {
match self {
Self::Arboard(cb) => cb.cb.clear().map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
- Self::Wayland(cb) => {
- cb.set_text("");
- Ok(())
- }
+ Self::Wayland(cb) => cb.copy(&[]),
}
}
/// The read that got no answer in its wait, when a paste of one of `mimes` can take its
- /// answer ([`Arboard::unanswered`], [`TextReads::unanswered`](super::wayland::TextReads::unanswered)).
+ /// answer ([`Arboard::unanswered`], [`Reads::unanswered`](super::wayland::Reads::unanswered)).
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn unanswered(&mut self, mimes: &[&'static str]) -> Option {
match self {
@@ -1223,9 +1232,15 @@ mod portable {
Some(ClipboardRead::Later(Box::new(move || unanswered.paste(ours.as_ref(), &mimes))))
}
- /// Text and copied files are read here, and work that reads arboard's image comes back.
+ /// Text and copied files are read here, and work that reads arboard's image comes back. On
+ /// the Wayland core protocol the first of `mimes` the clipboard offers is read: text here,
+ /// SVG and PNG by the work that comes back, which waits [`IMAGE_WAIT`] at most.
fn read_now_or_later(&mut self, mimes: &[&'static str]) -> Option {
let cb = self.cb(Asker::User).ok()?;
+ #[cfg(target_os = "linux")]
+ if let Backend::Wayland(cb) = cb {
+ return cb.read(mimes, IMAGE_WAIT);
+ }
if let Some(f) = copied_file(cb.file_list(), mimes) {
return Some(ClipboardRead::Now(f));
}
@@ -1247,13 +1262,23 @@ mod portable {
}
impl SystemClipboard for Clipboard {
+ /// arboard takes the text, else the PNG as a bitmap; the Wayland core protocol offers the
+ /// SVG, PNG and text together ([`DataClipboard::copy`](super::wayland::DataClipboard::copy)).
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
+ let cb = self.cb(Asker::User)?;
+ #[cfg(target_os = "linux")]
+ if let Backend::Wayland(wl) = cb {
+ wl.copy(flavours)?;
+ // What a read got late was on the clipboard before this write.
+ wl.forget_unanswered();
+ self.ours = wl.holds_ours().then_some(Ours::Source);
+ return Ok(());
+ }
let text = text_of(flavours);
let image = match &text {
Some(_) => None,
None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)),
};
- let cb = self.cb(Asker::User)?;
let ours = match (text, image) {
(Some(t), _) => {
cb.set_text(t.as_str())?;
@@ -1281,6 +1306,8 @@ mod portable {
match &self.ours {
Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t),
Some(Ours::Image(w, h)) => cb.image_now().is_ok_and(|now| (now.width, now.height) == (*w, *h)),
+ #[cfg(target_os = "linux")]
+ Some(Ours::Source) => cb.holds_source(),
None => false,
}
}
@@ -1307,7 +1334,8 @@ mod portable {
read
}
- /// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no
+ /// Text, on macOS vector art or a bitmap (by the pasteboard's types), and on the Wayland
+ /// core protocol SVG, text or PNG (by the types offered, nothing read). arboard has no
/// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys
/// take it, the menu item waits for text.
fn has(&mut self, mimes: &[&'static str]) -> bool {
@@ -1315,7 +1343,12 @@ mod portable {
if super::mac::has(mimes) {
return true;
}
- mimes.contains(&TEXT) && self.cb(Asker::Probe).is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
+ let Ok(cb) = self.cb(Asker::Probe) else { return false };
+ #[cfg(target_os = "linux")]
+ if let Backend::Wayland(wl) = cb {
+ return wl.has(mimes);
+ }
+ mimes.contains(&TEXT) && cb.get_text().is_ok_and(|t| !t.is_empty())
}
}
@@ -1656,45 +1689,58 @@ mod portable {
#[cfg(target_os = "linux")]
#[test]
fn a_paste_after_an_unanswered_core_protocol_text_read_waits_for_its_answer() {
- use super::super::wayland::TextReads;
+ use std::sync::{Arc, Mutex};
+
+ use super::super::wayland::{Reads, Seen};
use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
- /// Core protocol text reads whose owner answers with `text` only once the sender
+ /// Core protocol reads of a clipboard offering `types`, whose owner answers each read
+ /// of a text type with `text` and of `image/png` with `png`, only once the sender
/// returned is used or dropped.
- fn slow_text(text: &'static str) -> (TextReads, std::sync::mpsc::Sender<()>) {
+ fn slow_owner(types: &[&str], text: &'static str, png: &'static [u8]) -> (Reads, Arc>, std::sync::mpsc::Sender<()>) {
let (release, gate) = std::sync::mpsc::channel::<()>();
- let loads = Loads::spawn("test", Duration::ZERO, move |()| {
+ let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| {
let _ = gate.recv();
- Ok(text.to_string())
+ Ok(if wire == PNG { png.to_vec() } else { text.as_bytes().to_vec() })
})
.unwrap();
- (TextReads::new(loads), release)
+ let seen = Arc::new(Mutex::new(Seen::offering(types)));
+ (Reads::new(loads, seen.clone()), seen, release)
}
- // Text was copied here; the clipboard's owner answers the check with `text` late.
- let paste_after_check = |text: &'static str| {
- let (mut reads, release) = slow_text(text);
+ // The clipboard's owner answers the check with `text` late; `copied_here` when the
+ // clipboard holds the Copy made here meanwhile.
+ let paste_after_check = |text: &'static str, copied_here: bool| {
+ let (mut reads, seen, release) = slow_owner(&["text/plain;charset=utf-8", PNG], text, b"\x89PNG");
// The check whether the clipboard still holds what was copied here gets no answer
// in its wait (2 s on the UI thread, none here).
- assert!(reads.get(Duration::ZERO).is_err());
+ assert!(reads.text(Duration::ZERO).is_err());
// The paste's own read fails at once while the owner hasn't answered.
- assert!(reads.get(Duration::from_secs(600)).is_err());
+ assert!(reads.text(Duration::from_secs(600)).is_err());
// A paste of a bitmap alone can't take text: the read stays kept.
assert!(reads.unanswered(&[BITMAP]).is_none());
let late = Late::Wayland(reads.unanswered(&PASTE_ORDER).expect("the check's read is kept for the paste"));
- let ours = Ours::Text("copied here".into());
- let paste = std::thread::spawn(move || late.paste(Some(&ours), &PASTE_ORDER));
+ if copied_here {
+ let mut seen = seen.lock().unwrap();
+ seen.copy(vec!["text/plain;charset=utf-8", PNG]);
+ seen.selection(vec!["text/plain;charset=utf-8".into(), PNG.into()]);
+ }
+ let paste = std::thread::spawn(move || late.paste(None, &PASTE_ORDER));
drop(release);
paste.join().ok().flatten()
};
- let pasted = paste_after_check("copied elsewhere").expect("the other app's text is pasted");
+ let pasted = paste_after_check("copied elsewhere", false).expect("the other app's text is pasted");
assert_eq!((pasted.mime, pasted.data), (TEXT, b"copied elsewhere".to_vec()));
- // The text copied here, or nothing: the paste takes the internal clipboard.
- assert!(paste_after_check("copied here").is_none());
- assert!(paste_after_check("").is_none());
+ // Text that only names a picture goes with it: the picture is pasted, as at once.
+ let pasted = paste_after_check("https://example.com/art.png", false).expect("the picture is pasted");
+ assert_eq!((pasted.mime, pasted.data), (PNG, b"\x89PNG".to_vec()));
+ // The Copy made here, by its data source: the paste takes the internal clipboard.
+ assert!(paste_after_check("copied elsewhere", true).is_none());
+ // Empty text is not pasted: the picture beside it is.
+ assert_eq!(paste_after_check("", false).map(|f| f.mime), Some(PNG));
// An answer that came before the paste may be old: it is dropped, not pasted.
- let (mut reads, release) = slow_text("old");
- assert!(reads.get(Duration::ZERO).is_err());
+ let (mut reads, _, release) = slow_owner(&["text/plain"], "old", b"");
+ assert!(reads.text(Duration::ZERO).is_err());
drop(release);
while reads.waiting() {
std::thread::yield_now();
@@ -1705,14 +1751,14 @@ mod portable {
// again and takes the new owner's text.
let (release, gate) = std::sync::mpsc::channel::<()>();
let mut texts_read = 0;
- let loads = Loads::spawn("test", Duration::ZERO, move |()| {
+ let loads = Loads::spawn("test", Duration::ZERO, move |_: String| {
let _ = gate.recv();
texts_read += 1;
- Ok(if texts_read == 1 { "old owner" } else { "new owner" }.to_string())
+ Ok(if texts_read == 1 { "old owner" } else { "new owner" }.as_bytes().to_vec())
})
.unwrap();
- let mut reads = TextReads::new(loads);
- assert!(reads.get(Duration::ZERO).is_err());
+ let mut reads = Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&["UTF8_STRING"]))));
+ assert!(reads.text(Duration::ZERO).is_err());
let late = Late::Wayland(reads.unanswered(&[TEXT]).expect("the read is kept for the paste"));
let paste = std::thread::spawn(move || late.paste(None, &[TEXT]));
drop(release);
@@ -1737,81 +1783,311 @@ mod portable {
/// The compositor's clipboard through the core data-device protocol (`wl_data_device`), on the
/// window's own Wayland connection: what Copy and Paste use where the compositor lacks the
/// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on
-/// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the
-/// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's
-/// data with no limit, so reads run on a worker of their own. A read that gets no answer in that
-/// time is kept for a paste to wait for off the UI thread, as arboard's are.
+/// a Wayland session). A Copy offers its SVG as `image/svg+xml`, its PNG as `image/png` and its
+/// text under each of [`TEXT_TYPES`]; Paste reads the clipboard's SVG, text and PNG, and the paste
+/// check looks at the types offered without reading any data. The protocol gives the clipboard to
+/// the window with the keyboard focus only. A worker thread handles the protocol's events and
+/// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads
+/// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and
+/// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for
+/// a paste to wait for off the UI thread, as arboard's are.
#[cfg(target_os = "linux")]
mod wayland {
- use std::sync::{Arc, mpsc};
+ use std::io::{Read, Write};
+ use std::os::fd::AsFd as _;
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+ use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc};
use std::time::{Duration, Instant};
- use super::TEXT;
+ use vectorcraft_engine::cmd::clipboard::names_a_picture;
+ use vectorcraft_ui_egui::ClipboardRead;
+ use wayland_client::backend::ObjectData;
+ use wayland_client::globals::{GlobalListContents, registry_queue_init};
+ use wayland_client::protocol::wl_callback::{self, WlCallback};
+ use wayland_client::protocol::wl_data_device::{self, WlDataDevice};
+ use wayland_client::protocol::wl_data_device_manager::{self, WlDataDeviceManager};
+ use wayland_client::protocol::wl_data_offer::{self, WlDataOffer};
+ use wayland_client::protocol::wl_data_source::{self, WlDataSource};
+ use wayland_client::protocol::wl_keyboard::{self, WlKeyboard};
+ use wayland_client::protocol::wl_pointer::{self, WlPointer};
+ use wayland_client::protocol::wl_registry::{self, WlRegistry};
+ use wayland_client::protocol::wl_seat::{self, WlSeat};
+ use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum};
+
use super::loads::{Asker, GONE, LOAD_WAIT, Loads, wait_for_answer};
+ use super::{BITMAP, Flavour, PNG, SVG, TEXT};
- /// What a text read gives.
- type Text = Result;
+ /// The types text is offered under and read from, best first: `text/plain;charset=utf-8` (GTK
+ /// and Qt apps), `UTF8_STRING` (X11 apps through XWayland) and `text/plain`.
+ const TEXT_TYPES: [&str; 3] = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"];
+ /// The most bytes a read takes from the app that copied (untrusted data), VectorCraft's own
+ /// limit: a read of more fails.
+ const MAX_BYTES: usize = 64 << 20;
+ /// The most pastes of a Copy's data that are sent at once; a paste beyond them gets nothing.
+ const MAX_SENDS: usize = 8;
+ /// The most types of an offer that are kept (untrusted data).
+ const MAX_TYPES: usize = 256;
- pub struct TextClipboard {
- /// Writes: a store only hands the text to smithay-clipboard's thread.
- cb: smithay_clipboard::Clipboard,
- /// Reads, on a worker with a second clipboard on the same connection.
- reads: TextReads,
- /// Held as long as the clipboards: their connection is the window's.
- _window: Arc,
+ /// What a read gives: the bytes, or why there are none.
+ type Got = Result, String>;
+
+ fn lock(m: &Mutex) -> MutexGuard<'_, T> {
+ m.lock().unwrap_or_else(PoisonError::into_inner)
}
- /// Text reads on a worker, and the last one that got no answer in its wait.
- pub(super) struct TextReads {
- loads: Loads<(), Text>,
- /// The last read that got no answer in its wait, until a paste takes it or the next read
- /// is sent.
+ /// The types a Copy of `flavours` offers, each with its data: SVG as `image/svg+xml`, PNG as
+ /// `image/png` and text under each of [`TEXT_TYPES`]. Flavours with no data and other flavours
+ /// (PDF) are not offered.
+ fn copy_types(flavours: &[Flavour]) -> Vec<(&'static str, Arc<[u8]>)> {
+ let mut out: Vec<(&'static str, Arc<[u8]>)> = vec![];
+ for f in flavours.iter().filter(|f| !f.data.is_empty()) {
+ let types: &[&'static str] = match f.mime {
+ SVG => &[SVG],
+ PNG => &[PNG],
+ TEXT => &TEXT_TYPES,
+ _ => &[],
+ };
+ let data: Arc<[u8]> = f.data.as_slice().into();
+ for &t in types {
+ if !out.iter().any(|(o, _)| *o == t) {
+ out.push((t, data.clone()));
+ }
+ }
+ }
+ out
+ }
+
+ /// The type among `offered` that a read of `mime` asks for, in any letter case: SVG as
+ /// `image/svg+xml`, text as the first of [`TEXT_TYPES`] offered, a bitmap as `image/png`.
+ fn wire_type<'a>(mime: &str, offered: &'a [String]) -> Option<&'a str> {
+ let wanted: &[&str] = match mime {
+ SVG => &[SVG],
+ TEXT => &TEXT_TYPES,
+ BITMAP | PNG => &[PNG],
+ _ => &[],
+ };
+ wanted.iter().find_map(|w| offered.iter().find(|o| o.eq_ignore_ascii_case(w))).map(String::as_str)
+ }
+
+ /// The flavour a read of `mime` makes of `data`: SVG as it is, a bitmap as PNG, text with its
+ /// line ends as `\n`. None when `data` is empty.
+ fn flavour(mime: &'static str, data: Vec) -> Option {
+ if data.is_empty() {
+ return None;
+ }
+ Some(match mime {
+ TEXT => Flavour { mime: TEXT, data: String::from_utf8_lossy(&data).replace("\r\n", "\n").replace('\r', "\n").into_bytes() },
+ BITMAP | PNG => Flavour { mime: PNG, data },
+ _ => Flavour { mime, data },
+ })
+ }
+
+ /// What a paste of one of `mimes` takes from a clipboard offering `offered`, `read` reading the
+ /// offer's type for each one tried: the first of `mimes` offered with data, except that text
+ /// that is blank or only names a picture gives way to the PNG when `mimes` has a bitmap
+ /// (`adopt_system_clipboard_or_defer` in `vectorcraft-ui-egui`).
+ fn pick(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option>) -> Option {
+ let mut take = |mime: &'static str| flavour(mime, read(wire_type(mime, offered)?)?);
+ let first = mimes.iter().find_map(|&m| take(m))?;
+ if first.mime == TEXT && mimes.contains(&BITMAP) {
+ let text = String::from_utf8_lossy(&first.data);
+ if text.trim().is_empty() || names_a_picture(&text) {
+ return take(BITMAP).or(Some(first));
+ }
+ }
+ Some(first)
+ }
+
+ /// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read.
+ fn read_capped(from: impl Read, cap: usize) -> Got {
+ let mut data = vec![];
+ from.take(cap as u64 + 1).read_to_end(&mut data).map_err(|e| format!("couldn't read the clipboard's data: {e}"))?;
+ if data.len() > cap {
+ return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20));
+ }
+ Ok(data)
+ }
+
+ /// The threads that send a Copy's data to the apps that paste it, counted.
+ #[derive(Clone, Default)]
+ struct Sends(Arc);
+
+ impl Sends {
+ /// `data` written to `to` on a thread of its own, which then closes `to`; an app that
+ /// closes its end early ends the write (the process ignores `SIGPIPE`). While
+ /// [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false.
+ fn start(&self, mut to: impl Write + Send + 'static, data: Arc<[u8]>) -> bool {
+ if self.0.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS {
+ self.0.fetch_sub(1, Ordering::SeqCst);
+ return false;
+ }
+ let count = self.0.clone();
+ let send = move || {
+ let _ = to.write_all(&data);
+ drop(to);
+ count.fetch_sub(1, Ordering::SeqCst);
+ };
+ if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(send).is_err() {
+ self.0.fetch_sub(1, Ordering::SeqCst);
+ return false;
+ }
+ true
+ }
+
+ /// How many are being written.
+ #[cfg(test)]
+ fn running(&self) -> usize {
+ self.0.load(Ordering::SeqCst)
+ }
+ }
+
+ /// What the protocol worker has seen of the clipboard.
+ #[derive(Default)]
+ pub(super) struct Seen {
+ /// The types of the clipboard's offer, none when it is empty.
+ offered: Vec,
+ /// The types of this connection's last Copy, until its data source is canceled.
+ copied: Option>,
+ /// Whether the clipboard holds that Copy.
+ ours: bool,
+ }
+
+ impl Seen {
+ /// A clipboard offering `types`, copied elsewhere.
+ #[cfg(test)]
+ pub(super) fn offering(types: &[&str]) -> Self {
+ Self { offered: types.iter().map(|t| t.to_string()).collect(), ..Self::default() }
+ }
+
+ /// A Copy of `types` (none: the clipboard emptied) was given the clipboard.
+ pub(super) fn copy(&mut self, types: Vec<&'static str>) {
+ self.ours = !types.is_empty();
+ self.copied = Some(types).filter(|t| !t.is_empty());
+ }
+
+ /// The clipboard's offer has `offered` now. It holds the last Copy while that Copy's data
+ /// source is not canceled and the offer has the Copy's types, no more and no fewer, as the
+ /// compositor offers a source's types. An offer made before the Copy and handled after it
+ /// makes the Copy not ours until the Copy's own offer comes.
+ pub(super) fn selection(&mut self, offered: Vec) {
+ self.ours = self.copied.as_ref().is_some_and(|c| c.len() == offered.len() && c.iter().all(|t| offered.iter().any(|o| o == t)));
+ self.offered = offered;
+ }
+
+ /// The last Copy's data source was canceled: another app holds the clipboard.
+ pub(super) fn cancelled(&mut self) {
+ self.copied = None;
+ self.ours = false;
+ }
+ }
+
+ /// Reads of the clipboard's offer on a worker, and the last one that got no answer in its wait.
+ pub(super) struct Reads {
+ /// Each request is the offer's type to read.
+ loads: Loads,
+ seen: Arc>,
+ /// The last text read that got no answer in its wait, until a paste takes it or the next
+ /// read is sent.
unanswered: Option,
}
/// A text read that got no answer in its wait.
pub(super) struct Unanswered {
- answer: mpsc::Receiver,
+ answer: mpsc::Receiver,
sent: Instant,
- /// The worker, for the read made again once the answer comes.
- reads: Asker<(), Text>,
+ /// The worker, for the reads made once the answer comes.
+ reads: Asker,
+ seen: Arc>,
}
impl Unanswered {
- /// The text, read again once this read's answer comes, waiting for that answer until
- /// `limit` after the read was sent, and for the new one as long, [`LOAD_WAIT`] at least.
- /// The late answer may come from an app that lost the clipboard while it was awaited.
- pub(super) fn text(self, limit: Duration) -> Text {
+ /// What a paste of one of `mimes` takes once this read's answer comes, waiting for it until
+ /// `limit` after the read was sent: the clipboard read again as [`pick`] reads it, each read
+ /// waiting as long, [`LOAD_WAIT`] at least, since the app that answers late may have lost the
+ /// clipboard meanwhile. None when the answer doesn't come or the clipboard holds this
+ /// connection's Copy.
+ pub(super) fn paste(self, mimes: &[&'static str], limit: Duration) -> Option {
let left = |now: Instant| (self.sent + limit).saturating_duration_since(now);
- let _late = wait_for_answer(&self.answer, left(Instant::now()))?;
- wait_for_answer(&self.reads.ask(())?, LOAD_WAIT.max(left(Instant::now())))?
+ let _late = wait_for_answer(&self.answer, left(Instant::now())).ok()?;
+ let offered = {
+ let seen = lock(&self.seen);
+ if seen.ours {
+ return None;
+ }
+ seen.offered.clone()
+ };
+ pick(&offered, mimes, |wire| wait_for_answer(&self.reads.ask(wire.to_string()).ok()?, LOAD_WAIT.max(left(Instant::now()))).ok()?.ok())
}
}
- impl TextReads {
- pub(super) fn new(loads: Loads<(), Text>) -> Self {
- Self { loads, unanswered: None }
+ impl Reads {
+ pub(super) fn new(loads: Loads, seen: Arc>) -> Self {
+ Self { loads, seen, unanswered: None }
}
- /// The text, waiting `wait` at most. A read that gets no answer in that time is kept
- /// ([`Self::unanswered`]) for a paste to wait for off the UI thread; a read sent means the
- /// one kept has been answered, and its answer is dropped.
- pub(super) fn get(&mut self, wait: Duration) -> Text {
+ /// The offer's type for `mime`, when it has one.
+ fn wire(&self, mime: &str) -> Option {
+ wire_type(mime, &lock(&self.seen).offered).map(str::to_string)
+ }
+
+ /// The text, waiting `wait` at most; empty when the clipboard has none. A read that gets no
+ /// answer in that time is kept ([`Self::unanswered`]) for a paste to wait for off the UI
+ /// thread; a read sent means the one kept has been answered, and its answer is dropped.
+ pub(super) fn text(&mut self, wait: Duration) -> Result {
+ let Some(wire) = self.wire(TEXT) else { return Ok(String::new()) };
let sent = Instant::now();
- let answer = self.loads.ask(())?;
+ let answer = self.loads.ask(wire)?;
self.unanswered = None;
match wait_for_answer(&answer, wait) {
- Ok(text) => text,
+ Ok(got) => Ok(flavour(TEXT, got?).map(|f| String::from_utf8_lossy(&f.data).into_owned()).unwrap_or_default()),
Err(why) => {
if why != GONE {
- self.unanswered = Some(Unanswered { answer, sent, reads: self.loads.asker() });
+ self.unanswered = Some(Unanswered { answer, sent, reads: self.loads.asker(), seen: self.seen.clone() });
}
Err(why)
}
}
}
- /// The read that got no answer in its wait, while its answer has still not come and a
+ /// What a paste of one of `mimes` reads: the first the clipboard offers. Text is read here,
+ /// [`LOAD_WAIT`] at most, and pasted unless it is empty; SVG and PNG are asked for here and
+ /// waited for, `wait` at most, by the work that comes back. When that read fails or is
+ /// empty, the work reads the rest of `mimes` as [`pick`] does, each read waiting until
+ /// `wait` after the first was sent, [`LOAD_WAIT`] at least.
+ pub(super) fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option {
+ mimes.iter().enumerate().find_map(|(i, &mime)| {
+ let wire = self.wire(mime)?;
+ if mime == TEXT {
+ let text = self.text(LOAD_WAIT).ok().filter(|t| !t.is_empty())?;
+ return Some(ClipboardRead::Now(Flavour { mime: TEXT, data: text.into_bytes() }));
+ }
+ let sent = Instant::now();
+ let answer = self.loads.ask(wire).ok()?;
+ self.unanswered = None;
+ let (reads, seen, rest) = (self.loads.asker(), self.seen.clone(), mimes.get(i + 1..).unwrap_or_default().to_vec());
+ Some(ClipboardRead::Later(Box::new(move || {
+ if let Some(f) = wait_for_answer(&answer, wait).ok().and_then(Result::ok).and_then(|data| flavour(mime, data)) {
+ return Some(f);
+ }
+ let offered = lock(&seen).offered.clone();
+ let left = || LOAD_WAIT.max((sent + wait).saturating_duration_since(Instant::now()));
+ pick(&offered, &rest, |wire| wait_for_answer(&reads.ask(wire.to_string()).ok()?, left()).ok()?.ok())
+ })))
+ })
+ }
+
+ /// Whether the clipboard offers one of `mimes`; nothing is read.
+ pub(super) fn has(&self, mimes: &[&'static str]) -> bool {
+ let seen = lock(&self.seen);
+ mimes.iter().any(|m| wire_type(m, &seen.offered).is_some())
+ }
+
+ /// Whether the clipboard holds this connection's last Copy.
+ pub(super) fn holds_ours(&self) -> bool {
+ lock(&self.seen).ours
+ }
+
+ /// The text read that got no answer in its wait, while its answer has still not come and a
/// paste of one of `mimes` can take text. Once it has come the answer may be old, and it is
/// dropped: a new read is made.
pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option {
@@ -1819,6 +2095,11 @@ mod wayland {
self.loads.waiting().then_some(unanswered)
}
+ /// Drops the read that got no answer in its wait: a paste no longer takes its answer.
+ pub(super) fn forget_unanswered(&mut self) {
+ self.unanswered = None;
+ }
+
/// Whether a read sent is still unanswered.
#[cfg(test)]
pub(super) fn waiting(&self) -> bool {
@@ -1826,55 +2107,591 @@ mod wayland {
}
}
- /// The read worker's clipboard and the window whose connection it uses, dropped in that order.
- struct Loader {
- cb: smithay_clipboard::Clipboard,
+ /// The protocol objects of a [`DataClipboard`], kept up to date by its worker.
+ #[derive(Default)]
+ struct Objects {
+ device: Option,
+ keyboard: Option,
+ pointer: Option,
+ /// The serial of the last key press, click or keyboard focus, which a Copy takes the
+ /// clipboard with.
+ serial: Option,
+ /// The clipboard's offer.
+ selection: Option,
+ /// The offer of a drag over the window.
+ drag: Option,
+ /// The last Copy's data source.
+ source: Option,
+ }
+
+ /// An offer's types, as its events give them.
+ #[derive(Default)]
+ struct OfferData(Mutex>);
+
+ /// A Copy's types with their data.
+ struct SourceData(Vec<(&'static str, Arc<[u8]>)>);
+
+ /// What the protocol worker's event handlers change.
+ struct State {
+ objects: Arc>,
+ seen: Arc>,
+ sends: Sends,
+ }
+
+ impl State {
+ /// Lets go of every object, once the clipboard is dropped.
+ fn close(&mut self, seat: &WlSeat) {
+ let mut o = lock(&self.objects);
+ for offer in [o.selection.take(), o.drag.take()].into_iter().flatten() {
+ offer.destroy();
+ }
+ if let Some(source) = o.source.take() {
+ source.destroy();
+ }
+ if let Some(device) = o.device.take().filter(|d| d.version() >= 2) {
+ device.release();
+ }
+ if let Some(keyboard) = o.keyboard.take().filter(|k| k.version() >= 3) {
+ keyboard.release();
+ }
+ if let Some(pointer) = o.pointer.take().filter(|p| p.version() >= 3) {
+ pointer.release();
+ }
+ if seat.version() >= 5 {
+ seat.release();
+ }
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {}
+ }
+
+ impl Dispatch for State {
+ fn event(_: &mut Self, _: &WlCallback, _: wl_callback::Event, _: &(), _: &Connection, _: &QueueHandle) {}
+ }
+
+ impl Dispatch for State {
+ fn event(_: &mut Self, _: &WlDataDeviceManager, _: wl_data_device_manager::Event, _: &(), _: &Connection, _: &QueueHandle) {}
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, seat: &WlSeat, event: wl_seat::Event, _: &(), _: &Connection, qh: &QueueHandle) {
+ let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event else { return };
+ let mut o = lock(&state.objects);
+ if !caps.contains(wl_seat::Capability::Keyboard) {
+ if let Some(keyboard) = o.keyboard.take().filter(|k| k.version() >= 3) {
+ keyboard.release();
+ }
+ } else if o.keyboard.is_none() {
+ o.keyboard = Some(seat.get_keyboard(qh, ()));
+ }
+ if !caps.contains(wl_seat::Capability::Pointer) {
+ if let Some(pointer) = o.pointer.take().filter(|p| p.version() >= 3) {
+ pointer.release();
+ }
+ } else if o.pointer.is_none() {
+ o.pointer = Some(seat.get_pointer(qh, ()));
+ }
+ }
+ }
+
+ /// The serial a Copy takes the clipboard with after the keyboard event `event`, `serial` before
+ /// it: a key or the keyboard focus coming in sets it, the focus leaving clears it, and other
+ /// events leave it. A modifier change's serial is not an input event's, and the release of Ctrl
+ /// after Ctrl+C comes before the Copy's data is ready.
+ fn keyboard_serial(event: &wl_keyboard::Event, serial: Option) -> Option {
+ match event {
+ wl_keyboard::Event::Enter { serial, .. } | wl_keyboard::Event::Key { serial, .. } => Some(*serial),
+ wl_keyboard::Event::Leave { .. } => None,
+ _ => serial,
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, _: &WlKeyboard, event: wl_keyboard::Event, _: &(), _: &Connection, _: &QueueHandle) {
+ let mut o = lock(&state.objects);
+ o.serial = keyboard_serial(&event, o.serial);
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, _: &WlPointer, event: wl_pointer::Event, _: &(), _: &Connection, _: &QueueHandle) {
+ if let wl_pointer::Event::Button { serial, .. } = event {
+ lock(&state.objects).serial = Some(serial);
+ }
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, _: &WlDataDevice, event: wl_data_device::Event, _: &(), _: &Connection, _: &QueueHandle) {
+ let mut o = lock(&state.objects);
+ match event {
+ wl_data_device::Event::Selection { id } => {
+ let mut offered = id.as_ref().and_then(|offer| offer.data::()).map(|d| lock(&d.0).clone()).unwrap_or_default();
+ offered.dedup();
+ if let Some(old) = std::mem::replace(&mut o.selection, id) {
+ old.destroy();
+ }
+ lock(&state.seen).selection(offered);
+ }
+ wl_data_device::Event::Enter { id, .. } => {
+ if let Some(old) = std::mem::replace(&mut o.drag, id) {
+ old.destroy();
+ }
+ }
+ wl_data_device::Event::Leave | wl_data_device::Event::Drop => {
+ if let Some(old) = o.drag.take() {
+ old.destroy();
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// The one event of a data device that makes an object is `data_offer`.
+ fn event_created_child(_: u16, qh: &QueueHandle) -> Arc {
+ qh.make_data::(OfferData::default())
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(_: &mut Self, _: &WlDataOffer, event: wl_data_offer::Event, data: &OfferData, _: &Connection, _: &QueueHandle) {
+ if let wl_data_offer::Event::Offer { mime_type } = event {
+ let mut types = lock(&data.0);
+ if types.len() < MAX_TYPES {
+ types.push(mime_type);
+ }
+ }
+ }
+ }
+
+ impl Dispatch for State {
+ fn event(state: &mut Self, source: &WlDataSource, event: wl_data_source::Event, data: &SourceData, _: &Connection, _: &QueueHandle) {
+ match event {
+ // A type the Copy doesn't offer gets nothing: dropping `fd` closes it.
+ wl_data_source::Event::Send { mime_type, fd } => {
+ if let Some((_, bytes)) = data.0.iter().find(|(t, _)| t.eq_ignore_ascii_case(&mime_type)) {
+ state.sends.start(std::fs::File::from(fd), bytes.clone());
+ }
+ }
+ wl_data_source::Event::Cancelled => {
+ source.destroy();
+ let mut o = lock(&state.objects);
+ if o.source.as_ref() == Some(source) {
+ o.source = None;
+ lock(&state.seen).cancelled();
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+
+ /// Reads the clipboard's offer for the read worker.
+ struct Receiver {
+ conn: Connection,
+ objects: Arc>,
+ /// Held as long as the connection, which is the window's.
_window: Arc,
}
- impl Loader {
- fn load(&self) -> Result {
- self.cb.load().map_err(|e| e.to_string())
+ impl Receiver {
+ /// The clipboard's data of type `wire`, read until the app that copied closes the pipe,
+ /// [`MAX_BYTES`] at most.
+ fn receive(&self, wire: String) -> Got {
+ let offer = lock(&self.objects).selection.clone().ok_or("the clipboard is empty")?;
+ let (from, to) = std::io::pipe().map_err(|e| format!("couldn't open a pipe for the clipboard's data: {e}"))?;
+ offer.receive(wire, to.as_fd());
+ // The request carries a copy of the write end: once it is sent, the app that copied
+ // holds the only one, and the read ends when that app closes it.
+ drop(to);
+ self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))?;
+ read_capped(from, MAX_BYTES)
}
}
- impl TextClipboard {
- /// None when `window` isn't a Wayland window or the read worker can't start.
- #[allow(unsafe_code)]
+ pub struct DataClipboard {
+ reads: Reads,
+ objects: Arc>,
+ manager: WlDataDeviceManager,
+ conn: Connection,
+ qh: QueueHandle,
+ /// Tells the protocol worker to stop.
+ stop: Arc,
+ /// Held as long as the connection, which is the window's.
+ _window: Arc,
+ }
+
+ impl DataClipboard {
+ /// None when `window` isn't a Wayland window, the compositor has no data-device manager or
+ /// seat, or a worker can't start.
pub fn new(window: Arc) -> Option {
- use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle};
- let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None };
- let display = d.display.as_ptr();
- // SAFETY: the display is winit's open `wl_display`, which outlives `window`; each
- // clipboard is held beside a clone of `window` and dropped before it. smithay-clipboard
- // makes its own event queue on the display and doesn't close it.
- let (cb, reader) = unsafe { (smithay_clipboard::Clipboard::new(display), smithay_clipboard::Clipboard::new(display)) };
- let loader = Loader { cb: reader, _window: window.clone() };
- // `loader.load()`, not `loader.cb.load()`: a closure moves only the fields it names,
- // and the worker must hold the window as long as its clipboard.
+ let conn = connection(&window)?;
+ let (globals, mut queue) = registry_queue_init::(&conn).ok()?;
+ let qh = queue.handle();
+ let manager: WlDataDeviceManager = globals.bind(&qh, 1..=3, ()).ok()?;
+ let seat: WlSeat = globals.bind(&qh, 1..=5, ()).ok()?;
+ let (objects, seen) = (Arc::>::default(), Arc::>::default());
+ lock(&objects).device = Some(manager.get_data_device(&seat, &qh, ()));
+ let mut state = State { objects: objects.clone(), seen: seen.clone(), sends: Sends::default() };
+ // The seat's keyboard and pointer, and the clipboard's offer when the window has focus.
+ queue.roundtrip(&mut state).ok()?;
+ let receiver = Receiver { conn: conn.clone(), objects: objects.clone(), _window: window.clone() };
// Dropping doesn't wait for an unanswered read: only arboard's X11 handles hand what the
// app copied on when the last one goes.
- let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |()| loader.load())?;
- Some(Self { cb, reads: TextReads::new(loads), _window: window })
+ let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |wire| receiver.receive(wire))?;
+ let stop = Arc::new(AtomicBool::new(false));
+ let (stopped, held) = (stop.clone(), window.clone());
+ let work = move || {
+ while !stopped.load(Ordering::SeqCst) && queue.blocking_dispatch(&mut state).is_ok() {}
+ state.close(&seat);
+ let _ = queue.flush();
+ drop(held);
+ };
+ std::thread::Builder::new().name("wayland-clipboard".into()).spawn(work).ok()?;
+ Some(Self { reads: Reads::new(loads, seen), objects, manager, conn, qh, stop, _window: window })
}
- pub fn get_text(&mut self) -> Result {
- self.reads.get(LOAD_WAIT)
+ /// Gives the clipboard to a Copy of `flavours` ([`copy_types`]); none empties it. Err when
+ /// the seat has no data device or the window has had no input to take the clipboard with.
+ pub fn copy(&mut self, flavours: &[Flavour]) -> Result<(), String> {
+ let offered = copy_types(flavours);
+ let mut o = lock(&self.objects);
+ let device = o.device.clone().ok_or("the seat has no clipboard")?;
+ let serial = o.serial.ok_or("the window has had no key press or click to copy with")?;
+ let types: Vec<&'static str> = offered.iter().map(|(t, _)| *t).collect();
+ let source = (!offered.is_empty()).then(|| {
+ let source = self.manager.create_data_source(&self.qh, SourceData(offered));
+ for t in &types {
+ source.offer(t.to_string());
+ }
+ source
+ });
+ device.set_selection(source.as_ref(), serial);
+ if let Some(old) = std::mem::replace(&mut o.source, source) {
+ old.destroy();
+ }
+ lock(&self.reads.seen).copy(types);
+ drop(o);
+ self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))
}
- pub fn set_text(&self, text: &str) {
- self.cb.store(text);
+ pub fn text(&mut self) -> Result {
+ self.reads.text(LOAD_WAIT)
+ }
+
+ /// What a paste of one of `mimes` reads ([`Reads::read`]), SVG and PNG waited for `wait` at
+ /// most.
+ pub fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option {
+ self.reads.read(mimes, wait)
+ }
+
+ pub fn has(&self, mimes: &[&'static str]) -> bool {
+ self.reads.has(mimes)
+ }
+
+ pub fn holds_ours(&self) -> bool {
+ self.reads.holds_ours()
}
/// The read that got no answer in its wait, when a paste of one of `mimes` can take its
- /// answer ([`TextReads::unanswered`]).
+ /// answer ([`Reads::unanswered`]).
pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option {
self.reads.unanswered(mimes)
}
/// Drops the read that got no answer in its wait: a paste no longer takes its answer.
pub fn forget_unanswered(&mut self) {
- self.reads.unanswered = None;
+ self.reads.forget_unanswered();
+ }
+ }
+
+ impl Drop for DataClipboard {
+ /// Stops the protocol worker, which a sync request's answer wakes.
+ fn drop(&mut self) {
+ self.stop.store(true, Ordering::SeqCst);
+ let _ = self.conn.display().sync(&self.qh, ());
+ let _ = self.conn.flush();
+ }
+ }
+
+ /// A connection on `window`'s Wayland display.
+ #[allow(unsafe_code)]
+ fn connection(window: &winit::window::Window) -> Option {
+ use wayland_backend::sys::client::Backend;
+ use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle};
+ let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None };
+ // SAFETY: the display is winit's open `wl_display`, which outlives `window`; every user of
+ // the connection holds a clone of `window`. A backend made from a foreign display does not
+ // close it when dropped.
+ let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
+ Some(Connection::from_backend(backend))
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+
+ fn flavour_of(mime: &'static str, data: &[u8]) -> Flavour {
+ Flavour { mime, data: data.to_vec() }
+ }
+
+ fn offered(types: &[&str]) -> Vec {
+ types.iter().map(|t| t.to_string()).collect()
+ }
+
+ #[test]
+ fn a_copy_offers_its_svg_png_and_text_each_with_its_own_data() {
+ let flavours = [flavour_of(TEXT, b" "), flavour_of(SVG, b" "), flavour_of(super::super::PDF, b"%PDF"), flavour_of(PNG, b"png")];
+ let types: Vec<(&str, Vec)> = copy_types(&flavours).into_iter().map(|(t, d)| (t, d.to_vec())).collect();
+ assert_eq!(
+ types,
+ [
+ ("text/plain;charset=utf-8", b" ".to_vec()),
+ ("UTF8_STRING", b" ".to_vec()),
+ ("text/plain", b" ".to_vec()),
+ (SVG, b" ".to_vec()),
+ (PNG, b"png".to_vec()),
+ ]
+ );
+ // With Include SVG Code off the engine makes no SVG flavour, and none is offered; a
+ // flavour with no data is not offered either.
+ let types: Vec<&str> = copy_types(&[flavour_of(PNG, b"png"), flavour_of(TEXT, b"")]).into_iter().map(|(t, _)| t).collect();
+ assert_eq!(types, [PNG]);
+ assert!(copy_types(&[]).is_empty());
+ }
+
+ #[test]
+ fn a_read_asks_for_the_offers_own_type() {
+ let all = offered(&["text/plain", "TEXT", "UTF8_STRING", "Image/PNG", "image/svg+xml", "application/pdf"]);
+ assert_eq!(wire_type(TEXT, &all), Some("UTF8_STRING"));
+ assert_eq!(wire_type(TEXT, &offered(&["text/plain", "text/plain;charset=UTF-8"])), Some("text/plain;charset=UTF-8"));
+ assert_eq!(wire_type(BITMAP, &all), Some("Image/PNG"));
+ assert_eq!(wire_type(SVG, &all), Some("image/svg+xml"));
+ // PDF, EPS and metafiles are not read here, nor types no reader knows.
+ assert_eq!(wire_type(super::super::PDF, &all), None);
+ assert_eq!(wire_type(TEXT, &offered(&["text/html", "image/jpeg"])), None);
+ assert_eq!(wire_type(SVG, &[]), None);
+ }
+
+ /// What a paste of `mimes` takes from a clipboard holding `data` by type, and the types read.
+ fn pasted(data: &[(&str, &[u8])], mimes: &[&'static str]) -> (Option, Vec) {
+ let types: Vec = data.iter().map(|(t, _)| t.to_string()).collect();
+ let mut read = vec![];
+ let f = pick(&types, mimes, |wire| {
+ read.push(wire.to_string());
+ data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.to_vec())
+ });
+ (f, read)
+ }
+
+ #[test]
+ fn a_paste_takes_svg_then_text_then_png() {
+ use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
+ let svg: (&str, &[u8]) = (SVG, b"");
+ let text: (&str, &[u8]) = ("text/plain;charset=utf-8", b"a\r\nb");
+ let png: (&str, &[u8]) = (PNG, b"\x89PNG");
+ let (f, read) = pasted(&[text, png, svg], &PASTE_ORDER);
+ assert_eq!(f.map(|f| (f.mime, f.data)), Some((SVG, svg.1.to_vec())));
+ assert_eq!(read, [SVG]);
+ // Text before a bitmap, its line ends made `\n`.
+ let (f, _) = pasted(&[png, text], &PASTE_ORDER);
+ assert_eq!(f.map(|f| (f.mime, f.data)), Some((TEXT, b"a\nb".to_vec())));
+ // An offer of a PNG or SVG alone pastes it.
+ assert_eq!(pasted(&[png], &PASTE_ORDER).0.map(|f| (f.mime, f.data)), Some((PNG, png.1.to_vec())));
+ assert_eq!(pasted(&[svg], &PASTE_ORDER).0.map(|f| f.mime), Some(SVG));
+ // Blank text, or text that only names a picture, gives way to the picture.
+ for beside in [&b" \n"[..], b"https://example.com/art.png", b" "] {
+ let (f, read) = pasted(&[("UTF8_STRING", beside), png], &PASTE_ORDER);
+ assert_eq!(f.map(|f| f.mime), Some(PNG));
+ assert_eq!(read, ["UTF8_STRING", PNG]);
+ }
+ // With no picture, or a paste of text alone, the text is what there is.
+ assert_eq!(pasted(&[("text/plain", &b"https://example.com/art.png"[..])], &PASTE_ORDER).0.map(|f| f.mime), Some(TEXT));
+ assert_eq!(pasted(&[("text/plain", &b" "[..]), png], &[TEXT]).0.map(|f| f.mime), Some(TEXT));
+ // An empty clipboard, a type no reader knows, data that doesn't come or is empty.
+ assert_eq!(pasted(&[], &PASTE_ORDER).0, None);
+ assert_eq!(pasted(&[("application/x-other", &b"x"[..])], &PASTE_ORDER).0, None);
+ assert_eq!(pasted(&[(PNG, &b""[..])], &PASTE_ORDER).0, None);
+ let types = offered(&[PNG]);
+ assert_eq!(pick(&types, &PASTE_ORDER, |_| None), None);
+ }
+
+ #[test]
+ fn the_clipboard_holds_a_copy_until_another_app_takes_it() {
+ let mut seen = Seen::default();
+ let types = vec!["text/plain;charset=utf-8", SVG, PNG];
+ seen.copy(types.clone());
+ assert!(seen.ours, "a Copy holds the clipboard before its offer comes");
+ // An offer made before the Copy and handled after it.
+ seen.selection(offered(&[PNG]));
+ assert!(!seen.ours);
+ // The Copy's own offer, its types in another order.
+ seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"]));
+ assert!(seen.ours);
+ // Another app's copy: an offer with other types, then the source canceled.
+ seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8", "text/html"]));
+ assert!(!seen.ours);
+ seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"]));
+ seen.cancelled();
+ assert!(!seen.ours, "a canceled source no longer holds the clipboard");
+ // Another app's offer with the same types after that is not ours.
+ seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"]));
+ assert!(!seen.ours);
+ // An empty Copy empties the clipboard.
+ seen.copy(vec![]);
+ assert!(!seen.ours);
+ seen.selection(vec![]);
+ assert!(!seen.ours && seen.offered.is_empty());
+ }
+
+ #[test]
+ fn a_copy_takes_the_clipboard_with_a_key_press_not_a_modifier_change() {
+ let key = |serial| wl_keyboard::Event::Key { serial, time: 0, key: 46, state: WEnum::Value(wl_keyboard::KeyState::Pressed) };
+ let modifiers = |serial| wl_keyboard::Event::Modifiers { serial, mods_depressed: 0, mods_latched: 0, mods_locked: 0, group: 0 };
+ // Ctrl+C, then Ctrl released before the Copy's data is ready.
+ let serial = keyboard_serial(&key(7), None);
+ assert_eq!(keyboard_serial(&modifiers(8), serial), Some(7));
+ assert_eq!(keyboard_serial(&wl_keyboard::Event::RepeatInfo { rate: 25, delay: 600 }, Some(7)), Some(7));
+ assert_eq!(keyboard_serial(&modifiers(9), None), None);
+ }
+
+ #[test]
+ fn a_read_stops_at_its_limit() {
+ assert_eq!(MAX_BYTES, 64 * 1024 * 1024);
+ assert_eq!(read_capped(&b"12345678"[..], 8), Ok(b"12345678".to_vec()));
+ assert_eq!(read_capped(&b""[..], 8), Ok(vec![]));
+ assert_eq!(read_capped(&[0u8; 9][..], 8), Err("the clipboard's data is larger than 0 MiB".to_string()));
+ assert_eq!(read_capped(&vec![0u8; (2 << 20) + 1][..], 2 << 20), Err("the clipboard's data is larger than 2 MiB".to_string()));
+ /// A pipe that breaks.
+ struct Broken;
+ impl Read for Broken {
+ fn read(&mut self, _: &mut [u8]) -> std::io::Result {
+ Err(std::io::ErrorKind::BrokenPipe.into())
+ }
+ }
+ assert!(read_capped(Broken, 8).is_err_and(|e| e.starts_with("couldn't read the clipboard's data")));
+ }
+
+ #[test]
+ fn a_copys_data_is_sent_through_the_pipe_and_closed() {
+ let sends = Sends::default();
+ let (from, to) = std::io::pipe().unwrap();
+ let data: Arc<[u8]> = vec![7u8; 300_000].into();
+ assert!(sends.start(to, data.clone()));
+ // More than a pipe holds: the write ends once all of it is read, then the pipe closes.
+ assert_eq!(read_capped(from, MAX_BYTES).as_deref(), Ok(&data[..]));
+ // An app that closes its end before reading ends the send.
+ let (from, to) = std::io::pipe().unwrap();
+ drop(from);
+ assert!(sends.start(to, data.clone()));
+ while sends.running() > 0 {
+ std::thread::yield_now();
+ }
+ }
+
+ #[test]
+ fn pastes_that_read_nothing_hold_a_few_sends_at_most() {
+ let sends = Sends::default();
+ let data: Arc<[u8]> = vec![1u8; 300_000].into();
+ // Apps that ask for the data and never read it.
+ let stalled: Vec<_> = (0..MAX_SENDS)
+ .map(|_| {
+ let (from, to) = std::io::pipe().unwrap();
+ assert!(sends.start(to, data.clone()));
+ from
+ })
+ .collect();
+ // The next paste gets its pipe closed at once, with nothing written.
+ let (from, to) = std::io::pipe().unwrap();
+ assert!(!sends.start(to, data.clone()));
+ assert_eq!(read_capped(from, MAX_BYTES), Ok(vec![]));
+ // Once those apps close their pipes, the sends end and others are sent again.
+ drop(stalled);
+ while sends.running() > 0 {
+ std::thread::yield_now();
+ }
+ let (from, to) = std::io::pipe().unwrap();
+ assert!(sends.start(to, data.clone()));
+ assert_eq!(read_capped(from, MAX_BYTES).map(|d| d.len()), Ok(data.len()));
+ }
+
+ /// What a clipboard holds, by type.
+ type Held = &'static [(&'static str, &'static [u8])];
+ const PNG_ONLY: Held = &[(PNG, b"\x89PNG")];
+ const SVG_ONLY: Held = &[(SVG, b" ")];
+ const TEXT_ONLY: Held = &[("text/plain;charset=utf-8", b"words")];
+ const EMPTY_TEXT: Held = &[("text/plain", b"")];
+ const NOTHING: Held = &[];
+ const UNKNOWN: Held = &[("application/x-other", b"x")];
+
+ /// Reads of a clipboard holding `data`, answered at once.
+ fn reads_of(data: Held) -> Reads {
+ let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| {
+ data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.to_vec()).ok_or_else(|| "not offered".to_string())
+ })
+ .unwrap();
+ let types: Vec<&str> = data.iter().map(|(t, _)| *t).collect();
+ Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&types))))
+ }
+
+ #[test]
+ fn a_picture_or_svg_alone_can_be_pasted_and_is() {
+ use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
+ let wait = Duration::from_secs(600);
+ for (data, mime) in [(PNG_ONLY, PNG), (SVG_ONLY, SVG)] {
+ let mut reads = reads_of(data);
+ // The paste check enables Paste from the types alone.
+ assert!(reads.has(&PASTE_ORDER));
+ let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("{mime} is read off the UI thread") };
+ assert_eq!(work().map(|f| (f.mime, f.data)), Some((mime, data[0].1.to_vec())));
+ }
+ let mut reads = reads_of(TEXT_ONLY);
+ assert!(reads.has(&[TEXT]) && !reads.has(&[BITMAP, SVG]));
+ let Some(ClipboardRead::Now(f)) = reads.read(&PASTE_ORDER, wait) else { panic!("text is read at once") };
+ assert_eq!((f.mime, f.data), (TEXT, b"words".to_vec()));
+ // An empty clipboard, or one with no type a paste reads, enables nothing and pastes nothing.
+ for data in [NOTHING, UNKNOWN] {
+ let mut reads = reads_of(data);
+ assert!(!reads.has(&PASTE_ORDER));
+ assert!(reads.read(&PASTE_ORDER, wait).is_none());
+ assert_eq!(reads.text(wait), Ok(String::new()));
+ }
+ // Empty text is not pasted.
+ assert!(reads_of(EMPTY_TEXT).read(&[TEXT], wait).is_none());
+ }
+
+ #[test]
+ fn an_svg_that_sends_nothing_gives_way_to_the_text_or_png_beside_it() {
+ use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
+ const EMPTY_SVG_TEXT: Held = &[(SVG, b""), ("text/plain", b"words"), (PNG, b"\x89PNG")];
+ const EMPTY_SVG_PNG: Held = &[(SVG, b""), (PNG, b"\x89PNG")];
+ const EMPTY_SVG_URL: Held = &[(SVG, b""), ("text/plain", b"https://example.com/art.png"), (PNG, b"\x89PNG")];
+ let wait = Duration::from_secs(600);
+ const EMPTY_PNG: Held = &[(PNG, b"")];
+ for (data, mime, bytes) in
+ [(EMPTY_SVG_TEXT, TEXT, &b"words"[..]), (EMPTY_SVG_PNG, PNG, &b"\x89PNG"[..]), (EMPTY_SVG_URL, PNG, &b"\x89PNG"[..])]
+ {
+ // Held while the work runs, as the clipboard is.
+ let mut reads = reads_of(data);
+ let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("the SVG is asked for") };
+ assert_eq!(work().map(|f| (f.mime, f.data)), Some((mime, bytes.to_vec())));
+ }
+ // A failed PNG read, last of all, pastes nothing.
+ let mut reads = reads_of(EMPTY_PNG);
+ let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("the PNG is asked for") };
+ assert!(work().is_none());
+ }
+
+ #[test]
+ fn a_read_that_fails_pastes_nothing() {
+ let loads = Loads::spawn("test", Duration::ZERO, |_: String| Err::, _>("the clipboard's data is larger than 64 MiB".to_string()))
+ .unwrap();
+ let mut reads = Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&[PNG, "text/plain"]))));
+ assert_eq!(reads.text(Duration::from_secs(600)), Err("the clipboard's data is larger than 64 MiB".to_string()));
+ let Some(ClipboardRead::Later(work)) = reads.read(&[BITMAP], Duration::from_secs(600)) else { panic!("the PNG is asked for") };
+ assert!(work().is_none());
}
}
}
diff --git a/apps/vectorcraft/src/display_profile.rs b/apps/vectorcraft/src/display_profile.rs
new file mode 100644
index 0000000000..2be6e27aa5
--- /dev/null
+++ b/apps/vectorcraft/src/display_profile.rs
@@ -0,0 +1,629 @@
+//! Reads the ICC profile of the display that shows most of the main window, for Preferences ›
+//! Performance › Use Monitor Profile for Canvas, and hands it to [`DisplayProfile::report`].
+//!
+//! - Windows: the display's default profile for its standard color mode. A display with Advanced
+//! Color on (HDR or Automatic Color Management) is converted by Windows, and nothing is read.
+//! - X11: colord's profile for the RandR output, then the output's `_ICC_PROFILE` property, then
+//! the screen's `_ICC_PROFILE` when only one output is active.
+//! - Wayland, XWayland included: the compositor gives applications no display profile, and the
+//! canvas stays sRGB.
+//! - macOS: the window's layer is tagged sRGB and macOS converts it (`mac_color_space`).
+//!
+//! While the preference is on, a background thread reads the display every [`EVERY`] seconds,
+//! and at once when the window moves to another display.
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use vectorcraft_ui_egui::display_profile::{DisplayProfile, Found, Reading};
+
+/// Seconds between two readings of the same display.
+const EVERY: f64 = 10.0;
+/// Seconds between two checks of which display shows the window.
+const LOOK_EVERY: f64 = 1.0;
+
+/// Starts the readings from the main window's frames.
+#[derive(Default)]
+pub(crate) struct Reader {
+ /// When the last reading started, and the display it was for.
+ last: Option<(f64, Option)>,
+ /// When the window's display was last looked up.
+ looked: Option,
+ /// A reading is running.
+ busy: Arc,
+}
+
+/// Clears [`Reader::busy`] when the reading thread ends, also by a panic.
+struct Done(Arc);
+
+impl Drop for Done {
+ fn drop(&mut self) {
+ self.0.store(false, Ordering::Release);
+ }
+}
+
+impl Reader {
+ /// Starts a reading when one is due and none is running. `now` is the frame's time in seconds.
+ pub(crate) fn tick(&mut self, window: &winit::window::Window, now: f64, enabled: bool, profile: &DisplayProfile, ctx: &egui::Context) {
+ if !enabled || self.busy.load(Ordering::Acquire) || self.looked.is_some_and(|at| (at..at + LOOK_EVERY).contains(&now)) {
+ return;
+ }
+ self.looked = Some(now);
+ let display = window.current_monitor().and_then(|m| m.name());
+ if !due(self.last.as_ref(), now, display.as_deref()) {
+ return;
+ }
+ self.last = Some((now, display.clone()));
+ profile.pending();
+ self.busy.store(true, Ordering::Release);
+ let done = Done(self.busy.clone());
+ let (profile, ctx) = (profile.clone(), ctx.clone());
+ let spawned = std::thread::Builder::new().name("display profile".into()).spawn(move || {
+ let _done = done;
+ if profile.report(read(display)) {
+ ctx.request_repaint();
+ }
+ });
+ if let Err(e) = spawned {
+ log::warn!("couldn't start reading the display profile: {e}");
+ }
+ }
+}
+
+/// Whether a reading of `display` is due at `now`, after the last one (`last`): none yet, another
+/// display, [`EVERY`] seconds later, or a clock that went back.
+fn due(last: Option<&(f64, Option)>, now: f64, display: Option<&str>) -> bool {
+ last.is_none_or(|(at, shown)| shown.as_deref() != display || !(*at..at + EVERY).contains(&now))
+}
+
+#[cfg(windows)]
+fn read(display: Option) -> Reading {
+ let found = win::read(display.as_deref());
+ Reading { display, found }
+}
+
+#[cfg(target_os = "linux")]
+fn read(display: Option) -> Reading {
+ let found = if wayland_session(|k| std::env::var_os(k)) {
+ Found::NoProfile("a Wayland session doesn't give applications the display's profile".into())
+ } else {
+ x11::read(display.as_deref())
+ };
+ Reading { display, found }
+}
+
+#[cfg(all(target_os = "macos", feature = "wgpu"))]
+fn read(display: Option) -> Reading {
+ Reading { display, found: Found::System("macOS converts the window to the display's profile".into()) }
+}
+
+#[cfg(not(any(windows, target_os = "linux", all(target_os = "macos", feature = "wgpu"))))]
+fn read(display: Option) -> Reading {
+ Reading { display, found: Found::NoProfile("this build doesn't read display profiles on this system".into()) }
+}
+
+/// Whether the app runs in a Wayland session, read from the environment `var` gives. XWayland
+/// windows count: the variables stay set for them.
+#[cfg(any(target_os = "linux", test))]
+fn wayland_session(var: impl Fn(&str) -> Option) -> bool {
+ ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"].iter().any(|k| var(k).is_some_and(|v| !v.is_empty()))
+}
+
+/// The X11 reading from colord's answer `colord` (the profile file it names, read) and the X11
+/// properties `properties`: colord's profile when its file can be read, otherwise the
+/// properties' profile. When neither has one, the reason colord gave is kept.
+#[cfg(any(target_os = "linux", test))]
+fn x11_choice(colord: Option, String>>, properties: impl FnOnce() -> Result, String>) -> Found {
+ let colord_reason = match colord {
+ Some(Ok(Some(found @ Found::Profile { .. }))) => return found,
+ Some(Ok(Some(Found::NoProfile(e) | Found::System(e)))) | Some(Err(e)) => Some(e),
+ Some(Ok(None)) | None => None,
+ };
+ match properties() {
+ Ok(Some(found)) => found,
+ Ok(None) => Found::NoProfile(match colord_reason {
+ Some(e) => format!("no profile is set for this display ({e})"),
+ None => "no profile is set for this display".into(),
+ }),
+ Err(e) => Found::NoProfile(e),
+ }
+}
+
+/// `found` when it is not a profile or the profile can be used, otherwise why it can't, so the
+/// next source is tried.
+#[cfg(any(target_os = "linux", test))]
+fn usable(found: Found) -> Found {
+ match found {
+ Found::Profile { icc, from } => match vectorcraft_ui_egui::display_profile::check_profile(&icc) {
+ Ok(()) => Found::Profile { icc, from },
+ Err(e) => Found::NoProfile(format!("{from}: {e}")),
+ },
+ other => other,
+ }
+}
+
+/// The ICC file at `path`, refused before it is read when it is larger than [`MAX_PROFILE_BYTES`].
+///
+/// [`MAX_PROFILE_BYTES`]: vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES
+#[cfg(any(windows, target_os = "linux", test))]
+fn read_icc(path: &std::path::Path) -> Result, String> {
+ use std::io::Read as _;
+ let max = vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES;
+ let too_large = || format!("{} is larger than {} MiB", path.display(), max >> 20);
+ let file = std::fs::File::open(path).map_err(|e| format!("couldn't open {}: {e}", path.display()))?;
+ let len = file.metadata().map_err(|e| format!("couldn't read {}: {e}", path.display()))?.len();
+ if len > max as u64 {
+ return Err(too_large());
+ }
+ let mut bytes = Vec::with_capacity(len as usize);
+ file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|e| format!("couldn't read {}: {e}", path.display()))?;
+ if bytes.len() > max { Err(too_large()) } else { Ok(bytes) }
+}
+
+/// The file a profile `name` stands for: `name` itself when it is a path, otherwise the file of
+/// that name in the system's color folder `dir`.
+#[cfg(any(windows, test))]
+fn profile_path(dir: Option<&std::path::Path>, name: &str) -> std::path::PathBuf {
+ match dir {
+ Some(dir) if !name.contains(['\\', '/']) => dir.join(name),
+ _ => std::path::PathBuf::from(name),
+ }
+}
+
+/// `path`'s profile, or why there is none.
+#[cfg(any(windows, target_os = "linux"))]
+fn profile_file(path: &std::path::Path) -> Found {
+ let from = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
+ match read_icc(path) {
+ Ok(icc) => Found::Profile { icc, from },
+ Err(e) => Found::NoProfile(e),
+ }
+}
+
+#[cfg(windows)]
+#[allow(unsafe_code)]
+mod win {
+ use std::path::PathBuf;
+ use std::sync::OnceLock;
+
+ use vectorcraft_ui_egui::display_profile::Found;
+ use windows::Win32::Devices::Display::{
+ DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER,
+ DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
+ DisplayConfigGetDeviceInfo, GetDisplayConfigBufferSizes, QDC_ONLY_ACTIVE_PATHS, QueryDisplayConfig,
+ };
+ use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, HLOCAL, LUID, LocalFree};
+ use windows::Win32::Graphics::Gdi::{CreateDCW, DeleteDC};
+ use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW};
+ use windows::Win32::UI::ColorSystem::{
+ CPST_STANDARD_DISPLAY_COLOR_MODE, CPT_ICC, GetColorDirectoryW, GetICMProfileW, WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER,
+ };
+ use windows::core::{HRESULT, PCWSTR, PWSTR, s, w};
+
+ /// `ColorProfileGetDisplayDefault` and `ColorProfileGetDisplayUserScope` (mscms.dll), which
+ /// older Windows versions don't have.
+ type GetDefault = unsafe extern "system" fn(i32, LUID, u32, i32, i32, *mut PWSTR) -> HRESULT;
+ type GetScope = unsafe extern "system" fn(LUID, u32, *mut i32) -> HRESULT;
+
+ /// The display path that shows a GDI device.
+ struct Path {
+ source: u32,
+ target_adapter: LUID,
+ target: u32,
+ }
+
+ pub(super) fn read(display: Option<&str>) -> Found {
+ let Some(device) = display else { return Found::NoProfile("Windows didn't name the window's display".into()) };
+ let path = match find_path(device) {
+ Ok(path) => path,
+ Err(e) => return Found::NoProfile(e),
+ };
+ if path.as_ref().is_some_and(advanced_color) {
+ return Found::System("Windows manages this display's colors (Advanced Color)".into());
+ }
+ let name = match path.as_ref().and_then(default_profile) {
+ Some(Ok(name)) => name,
+ Some(Err(e)) => return Found::NoProfile(e),
+ // Older Windows, or a display without an active path: GDI's profile for the device.
+ None => match icm_profile(device) {
+ Ok(name) => name,
+ Err(e) => return Found::NoProfile(e),
+ },
+ };
+ match name {
+ Some(name) => super::profile_file(&super::profile_path(color_directory().as_deref(), &name)),
+ None => Found::NoProfile("no color profile is set for this display".into()),
+ }
+ }
+
+ fn wide(s: &str) -> Vec {
+ s.encode_utf16().chain([0]).collect()
+ }
+
+ fn until_nul(w: &[u16]) -> String {
+ String::from_utf16_lossy(&w[..w.iter().position(|&c| c == 0).unwrap_or(w.len())])
+ }
+
+ /// The active display paths.
+ fn paths() -> Result, String> {
+ for _ in 0..4 {
+ let (mut paths_len, mut modes_len) = (0u32, 0u32);
+ // SAFETY: both pointers are to live `u32`s.
+ let e = unsafe { GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, &mut modes_len) };
+ if e != ERROR_SUCCESS {
+ return Err(format!("Windows couldn't list the displays (error {})", e.0));
+ }
+ let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); paths_len as usize];
+ let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); modes_len as usize];
+ // SAFETY: the arrays hold as many elements as the lengths passed with them.
+ let e =
+ unsafe { QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, paths.as_mut_ptr(), &mut modes_len, modes.as_mut_ptr(), None) };
+ if e == ERROR_INSUFFICIENT_BUFFER {
+ // The displays changed between the two calls.
+ continue;
+ }
+ if e != ERROR_SUCCESS {
+ return Err(format!("Windows couldn't list the displays (error {})", e.0));
+ }
+ paths.truncate(paths_len as usize);
+ return Ok(paths);
+ }
+ Err("the displays kept changing while Windows listed them".into())
+ }
+
+ /// The path whose source is the GDI device `device` (`\\.\DISPLAY1`), if one is active.
+ fn find_path(device: &str) -> Result, String> {
+ for p in paths()? {
+ let mut name = DISPLAYCONFIG_SOURCE_DEVICE_NAME {
+ header: DISPLAYCONFIG_DEVICE_INFO_HEADER {
+ r#type: DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME,
+ size: size_of::() as u32,
+ adapterId: p.sourceInfo.adapterId,
+ id: p.sourceInfo.id,
+ },
+ ..Default::default()
+ };
+ // SAFETY: the header starts a `DISPLAYCONFIG_SOURCE_DEVICE_NAME` of the size it states.
+ if unsafe { DisplayConfigGetDeviceInfo(&mut name.header) } == 0 && until_nul(&name.viewGdiDeviceName).eq_ignore_ascii_case(device) {
+ return Ok(Some(Path { source: p.sourceInfo.id, target_adapter: p.targetInfo.adapterId, target: p.targetInfo.id }));
+ }
+ }
+ Ok(None)
+ }
+
+ /// Whether Windows color-manages the path's display itself: Advanced Color is on (HDR), or
+ /// wide color is enforced (Automatic Color Management on an SDR display).
+ fn advanced_color(path: &Path) -> bool {
+ let mut info = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO {
+ header: DISPLAYCONFIG_DEVICE_INFO_HEADER {
+ r#type: DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
+ size: size_of::() as u32,
+ adapterId: path.target_adapter,
+ id: path.target,
+ },
+ ..Default::default()
+ };
+ // SAFETY: the header starts a `DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO` of the size it states.
+ if unsafe { DisplayConfigGetDeviceInfo(&mut info.header) } != 0 {
+ return false;
+ }
+ // SAFETY: every bit pattern is a valid `u32`.
+ let bits = unsafe { info.Anonymous.value };
+ // advancedColorEnabled is bit 1 and wideColorEnforced bit 2.
+ bits & 0b110 != 0
+ }
+
+ fn api() -> Option<(GetDefault, GetScope)> {
+ static API: OnceLock> = OnceLock::new();
+ *API.get_or_init(|| {
+ // SAFETY: mscms.dll is loaded from System32 only, and stays loaded for the process.
+ let module = unsafe { LoadLibraryExW(w!("mscms.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) }.ok()?;
+ // SAFETY: the names are NUL-terminated, and the module is loaded.
+ let (default, scope) = unsafe {
+ (GetProcAddress(module, s!("ColorProfileGetDisplayDefault"))?, GetProcAddress(module, s!("ColorProfileGetDisplayUserScope"))?)
+ };
+ // SAFETY: both exports have these signatures (icm.h).
+ unsafe {
+ Some((
+ std::mem::transmute:: isize, GetDefault>(default),
+ std::mem::transmute:: isize, GetScope>(scope),
+ ))
+ }
+ })
+ }
+
+ /// The display's default profile name for its standard color mode, `None` without the API.
+ fn default_profile(path: &Path) -> Option, String>> {
+ let (get_default, get_scope) = api()?;
+ let mut scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0;
+ // SAFETY: `scope` is a live `i32`.
+ if unsafe { get_scope(path.target_adapter, path.source, &mut scope) }.is_err() {
+ scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0;
+ }
+ let mut name = PWSTR::null();
+ // SAFETY: `name` is a live pointer the function fills with a string it allocates.
+ let hr = unsafe { get_default(scope, path.target_adapter, path.source, CPT_ICC.0, CPST_STANDARD_DISPLAY_COLOR_MODE.0, &mut name) };
+ if hr.is_err() {
+ return Some(Err(format!("Windows couldn't give the display's profile (error {:#010x})", hr.0)));
+ }
+ if name.is_null() {
+ return Some(Ok(None));
+ }
+ // SAFETY: `name` is a NUL-terminated string from the call above, freed once with LocalFree.
+ let text = unsafe { name.to_string() };
+ // SAFETY: as above.
+ let _ = unsafe { LocalFree(Some(HLOCAL(name.0.cast()))) };
+ Some(text.map(|t| Some(t).filter(|t| !t.is_empty())).map_err(|e| format!("the display's profile name isn't readable: {e}")))
+ }
+
+ /// GDI's profile file for the display device `device`.
+ fn icm_profile(device: &str) -> Result, String> {
+ let device = wide(device);
+ // SAFETY: both strings are NUL-terminated and outlive the call.
+ let dc = unsafe { CreateDCW(w!("DISPLAY"), PCWSTR(device.as_ptr()), PCWSTR::null(), None) };
+ if dc.is_invalid() {
+ return Err("Windows couldn't open the display".into());
+ }
+ let mut len = 0u32;
+ // SAFETY: a null buffer asks for the length only.
+ let _ = unsafe { GetICMProfileW(dc, &mut len, None) };
+ let mut buf = vec![0u16; len.clamp(1, 32 * 1024) as usize];
+ len = buf.len() as u32;
+ // SAFETY: `buf` holds `len` characters.
+ let ok = unsafe { GetICMProfileW(dc, &mut len, Some(PWSTR(buf.as_mut_ptr()))) }.as_bool();
+ // SAFETY: `dc` came from CreateDCW above.
+ let _ = unsafe { DeleteDC(dc) };
+ Ok(ok.then(|| until_nul(&buf)).filter(|n| !n.is_empty()))
+ }
+
+ /// The system's color folder (`…\spool\drivers\color`).
+ fn color_directory() -> Option {
+ let mut bytes = 0u32;
+ // SAFETY: a null buffer asks for the size only, in bytes.
+ let _ = unsafe { GetColorDirectoryW(PCWSTR::null(), None, &mut bytes) };
+ let mut buf = vec![0u16; (bytes as usize / 2).clamp(1, 32 * 1024)];
+ bytes = (buf.len() * 2) as u32;
+ // SAFETY: `buf` holds `bytes` bytes.
+ unsafe { GetColorDirectoryW(PCWSTR::null(), Some(PWSTR(buf.as_mut_ptr())), &mut bytes) }.as_bool().then(|| PathBuf::from(until_nul(&buf)))
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use windows::Win32::Graphics::Gdi::{DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICEW, EnumDisplayDevicesW};
+
+ use super::*;
+
+ /// The GDI names (`\\.\DISPLAY1`) of the displays attached to the desktop.
+ fn attached_displays() -> Vec {
+ let mut names = Vec::new();
+ for i in 0.. {
+ let mut device = DISPLAY_DEVICEW { cb: size_of::() as u32, ..Default::default() };
+ // SAFETY: `device` is a live `DISPLAY_DEVICEW` with `cb` set to its size.
+ if !unsafe { EnumDisplayDevicesW(PCWSTR::null(), i, &mut device, 0) }.as_bool() {
+ break;
+ }
+ if device.StateFlags.contains(DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) {
+ names.push(until_nul(&device.DeviceName));
+ }
+ }
+ names
+ }
+
+ #[test]
+ fn windows_reads_each_attached_display_and_refuses_an_unknown_one() {
+ assert_eq!(read(None), Found::NoProfile("Windows didn't name the window's display".into()));
+ assert!(color_directory().is_none_or(|dir| dir.is_absolute()));
+ match find_path(r"\\.\DISPLAY999") {
+ Ok(path) => {
+ assert!(path.is_none());
+ assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile("Windows couldn't open the display".into()));
+ }
+ // A session without displays (a service): the reading gives Windows' error.
+ Err(e) => {
+ eprintln!("the displays couldn't be listed: {e}");
+ assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile(e));
+ return;
+ }
+ }
+ for name in attached_displays() {
+ // A display without an active path is read through GDI (`icm_profile`).
+ let path = find_path(&name).unwrap();
+ // winit names the display in GDI's spelling; the match ignores letter case.
+ let lower = find_path(&name.to_lowercase()).unwrap();
+ assert_eq!(lower.map(|p| (p.source, p.target)), path.as_ref().map(|p| (p.source, p.target)), "{name} in lowercase");
+ match read(Some(&name)) {
+ Found::Profile { icc, from } => {
+ eprintln!("{name}: profile {from}, {} bytes", icc.len());
+ assert!(!icc.is_empty() && !from.is_empty());
+ assert!(!path.as_ref().is_some_and(advanced_color));
+ }
+ Found::System(e) => {
+ eprintln!("{name}: system, {e}");
+ assert!(path.as_ref().is_some_and(advanced_color));
+ }
+ Found::NoProfile(e) => {
+ eprintln!("{name}: no profile, {e}");
+ assert!(!e.is_empty());
+ }
+ }
+ }
+ }
+ }
+}
+
+#[cfg(target_os = "linux")]
+mod x11 {
+ use std::collections::HashMap;
+ use std::path::Path;
+ use std::time::Duration;
+
+ use vectorcraft_ui_egui::display_profile::{Found, MAX_PROFILE_BYTES};
+ use x11rb::connection::Connection as _;
+ use x11rb::protocol::randr;
+ use x11rb::protocol::xproto::{self, AtomEnum};
+ use zbus::blocking::{connection::Builder, proxy::Proxy};
+ use zbus::zvariant::OwnedObjectPath;
+
+ const COLORD: &str = "org.freedesktop.ColorManager";
+ const COLORD_ROOT: &str = "/org/freedesktop/ColorManager";
+ const COLORD_MANAGER: &str = "org.freedesktop.ColorManager";
+ const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device";
+ const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile";
+
+ pub(super) fn read(output: Option<&str>) -> Found {
+ let colord = output.map(|o| colord_profile(o).map(|path| path.map(|path| super::usable(super::profile_file(Path::new(&path))))));
+ super::x11_choice(colord, || properties(output))
+ }
+
+ /// The file colord assigns the RandR output `output`.
+ fn colord_profile(output: &str) -> Result, String> {
+ let connection = Builder::system()
+ .map_err(|e| format!("couldn't connect to the system bus: {e}"))?
+ .method_timeout(Duration::from_secs(2))
+ .build()
+ .map_err(|e| format!("couldn't connect to the system bus: {e}"))?;
+ let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't reach colord: {e}"))?;
+ let devices: Vec =
+ manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list the displays: {e}"))?;
+ for path in devices {
+ let Ok(device) = Proxy::new(&connection, COLORD, &path, COLORD_DEVICE) else { continue };
+ let Ok(metadata) = device.get_property::>("Metadata") else { continue };
+ if metadata.get("XRANDR_name").map(String::as_str) != Some(output) {
+ continue;
+ }
+ // `*` asks for the device's default profile.
+ let Ok(profile_path) = device.call::<_, _, OwnedObjectPath>("GetProfileForQualifiers", &(vec!["*"],)) else { return Ok(None) };
+ let profile =
+ Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't reach colord's profile: {e}"))?;
+ return Ok(profile.get_property::("Filename").ok().filter(|f| !f.is_empty()));
+ }
+ Ok(None)
+ }
+
+ /// The `_ICC_PROFILE` property of the RandR output `output`, or of the screen when only one
+ /// output is active.
+ fn properties(output: Option<&str>) -> Result, String> {
+ let (conn, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?;
+ let root = conn.setup().roots.get(screen).ok_or_else(|| "the X11 display has no screen".to_string())?.root;
+ let atom = xproto::intern_atom(&conn, true, b"_ICC_PROFILE")
+ .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))?
+ .reply()
+ .map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))?
+ .atom;
+ if atom == 0 {
+ return Ok(None);
+ }
+ let resources = randr::get_screen_resources_current(&conn, root)
+ .map_err(|e| format!("couldn't list the X11 outputs: {e}"))?
+ .reply()
+ .map_err(|e| format!("couldn't list the X11 outputs: {e}"))?;
+ let (mut active, mut shown) = (0usize, None);
+ for o in resources.outputs {
+ let Ok(Ok(info)) = randr::get_output_info(&conn, o, resources.config_timestamp).map(|c| c.reply()) else { continue };
+ if info.connection != randr::Connection::CONNECTED || info.crtc == 0 {
+ continue;
+ }
+ active += 1;
+ if output.is_some_and(|name| info.name == name.as_bytes()) {
+ shown = Some(o);
+ }
+ }
+ let longs = (MAX_PROFILE_BYTES / 4) as u32;
+ let too_large = || Found::NoProfile(format!("the display's profile is larger than {} MiB", MAX_PROFILE_BYTES >> 20));
+ if let Some(o) = shown
+ && let Ok(Ok(p)) = randr::get_output_property(&conn, o, atom, AtomEnum::ANY, 0, longs, false, false).map(|c| c.reply())
+ && !p.data.is_empty()
+ {
+ if p.bytes_after > 0 {
+ return Ok(Some(too_large()));
+ }
+ return Ok(Some(Found::Profile { icc: p.data, from: "the X11 output's _ICC_PROFILE".into() }));
+ }
+ if active != 1 {
+ return Ok(None);
+ }
+ let Ok(Ok(p)) = xproto::get_property(&conn, false, root, atom, AtomEnum::ANY, 0, longs).map(|c| c.reply()) else { return Ok(None) };
+ if p.value.is_empty() {
+ return Ok(None);
+ }
+ if p.bytes_after > 0 {
+ return Ok(Some(too_large()));
+ }
+ Ok(Some(Found::Profile { icc: p.value, from: "the X11 screen's _ICC_PROFILE".into() }))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_reading_is_due_first_after_ten_seconds_and_when_the_window_changes_display() {
+ assert!(due(None, 0.0, None));
+ let last = (100.0, Some("DP-1".to_string()));
+ assert!(!due(Some(&last), 100.0, Some("DP-1")));
+ assert!(!due(Some(&last), 109.9, Some("DP-1")));
+ assert!(due(Some(&last), 110.0, Some("DP-1")));
+ assert!(due(Some(&last), 101.0, Some("HDMI-1")));
+ assert!(due(Some(&last), 101.0, None));
+ // A clock that went back.
+ assert!(due(Some(&last), 50.0, Some("DP-1")));
+ }
+
+ #[test]
+ fn wayland_sessions_are_told_apart_from_x11_by_the_environment() {
+ let env = |pairs: &'static [(&'static str, &'static str)]| {
+ move |k: &str| pairs.iter().find(|(n, _)| *n == k).map(|(_, v)| std::ffi::OsString::from(v))
+ };
+ assert!(wayland_session(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])));
+ assert!(wayland_session(env(&[("WAYLAND_SOCKET", "5")])));
+ assert!(!wayland_session(env(&[("DISPLAY", ":0")])));
+ assert!(!wayland_session(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":0")])));
+ }
+
+ #[test]
+ fn profile_files_larger_than_the_limit_are_refused_before_they_are_read() {
+ let dir = tempfile::tempdir().unwrap();
+ let small = dir.path().join("small.icc");
+ std::fs::write(&small, [1u8, 2, 3]).unwrap();
+ assert_eq!(read_icc(&small).unwrap(), [1, 2, 3]);
+ let big = dir.path().join("big.icc");
+ let f = std::fs::File::create(&big).unwrap();
+ f.set_len(vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES as u64 + 1).unwrap();
+ assert!(read_icc(&big).unwrap_err().contains("larger than 1 MiB"));
+ assert!(read_icc(&dir.path().join("missing.icc")).unwrap_err().starts_with("couldn't open"));
+ }
+
+ #[test]
+ fn x11_takes_the_output_property_when_colord_s_file_cannot_be_read() {
+ let icc = |b: u8| Found::Profile { icc: vec![b], from: "test".into() };
+ let property = || Ok(Some(icc(2)));
+ // colord's profile, read: the property is not asked for.
+ assert_eq!(x11_choice(Some(Ok(Some(icc(1)))), || panic!("not asked")), icc(1));
+ // colord names a file that can't be read, or has no profile, or isn't running.
+ assert_eq!(x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), property), icc(2));
+ assert_eq!(x11_choice(Some(Ok(None)), property), icc(2));
+ assert_eq!(x11_choice(Some(Err("no colord".into())), property), icc(2));
+ assert_eq!(x11_choice(None, property), icc(2));
+ // Neither has a profile: colord's reason is kept.
+ assert_eq!(
+ x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), || Ok(None)),
+ Found::NoProfile("no profile is set for this display (couldn't open a.icc)".into())
+ );
+ assert_eq!(x11_choice(None, || Ok(None)), Found::NoProfile("no profile is set for this display".into()));
+ assert_eq!(x11_choice(None, || Err("no X11".into())), Found::NoProfile("no X11".into()));
+ // colord's file holds no usable profile: the property is used.
+ let broken = usable(Found::Profile { icc: vec![1, 2, 3], from: "a.icc".into() });
+ assert_eq!(broken, Found::NoProfile("a.icc: the file is not an ICC profile".into()));
+ assert_eq!(x11_choice(Some(Ok(Some(broken))), property), icc(2));
+ assert_eq!(usable(Found::NoProfile("none".into())), Found::NoProfile("none".into()));
+ }
+
+ #[test]
+ fn a_profile_name_is_found_in_the_color_folder_and_a_path_is_kept() {
+ let dir = std::path::Path::new("C:/Windows/System32/spool/drivers/color");
+ assert_eq!(profile_path(Some(dir), "Display.icm"), dir.join("Display.icm"));
+ assert_eq!(profile_path(Some(dir), "D:\\profiles\\Display.icm"), std::path::PathBuf::from("D:\\profiles\\Display.icm"));
+ assert_eq!(profile_path(None, "Display.icm"), std::path::PathBuf::from("Display.icm"));
+ }
+}
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..7594bc2344 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -28,6 +28,7 @@ mod clipboard;
mod compositor_gpu;
mod control_server;
mod cursor_theme;
+mod display_profile;
#[cfg(feature = "wgpu")]
mod gpu;
mod hang_watch;
@@ -53,6 +54,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;
@@ -129,6 +132,8 @@ struct App {
held_files: Vec,
/// The time and UI zoom of the last monitor list ([`window::monitors_due`]).
monitors_listed: Option<(f64, f32)>,
+ /// Reads the profile of the display that shows the main window, for the canvas.
+ display_profile: display_profile::Reader,
/// When this frame's input hook started, for the hang report's frame times (`hang_watch`).
frame_start: Option,
}
@@ -632,6 +637,7 @@ fn main() -> std::process::ExitCode {
maximize_tries: 8,
held_files,
monitors_listed: None,
+ display_profile: Default::default(),
frame_start: None,
}))
}),
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/brush/src/lib.rs b/crates/brush/src/lib.rs
index f25615c589..080462d0b6 100644
--- a/crates/brush/src/lib.rs
+++ b/crates/brush/src/lib.rs
@@ -762,9 +762,40 @@ pub fn library(doc: &Document) -> Vec {
}
/// Parse a stored library value (malformed entries are skipped, values kept within their
-/// ranges; see [`Brush::from_json`]).
+/// ranges; see [`Brush::from_json`]). A name that repeats is kept by its first brush, and each
+/// later brush of that name is numbered ([`number_repeated_names`]).
pub fn parse_library(v: &serde_json::Value) -> Vec {
- v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default()
+ let mut lib: Vec = v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default();
+ number_repeated_names(&mut lib);
+ lib
+}
+
+/// Renames the second and later brushes of each repeated name in `lib` to the first "Name 2",
+/// "Name 3"… that no brush in `lib` has. The first brush of a name keeps it, so a stroke that names
+/// it draws the brush [`find`] gave it before.
+fn number_repeated_names(lib: &mut [Brush]) {
+ let mut seen = std::collections::HashSet::new();
+ if lib.iter().all(|b| seen.insert(b.name.as_str())) {
+ return;
+ }
+ let mut taken: std::collections::HashSet = lib.iter().map(|b| b.name.clone()).collect();
+ let mut kept = std::collections::HashSet::new();
+ // The next number to try for each repeated name, so the brushes of one name try each number
+ // once between them.
+ let mut next: std::collections::HashMap = std::collections::HashMap::new();
+ for b in lib.iter_mut() {
+ if kept.insert(b.name.clone()) {
+ continue;
+ }
+ let from = next.entry(b.name.clone()).or_insert(2);
+ // `taken` is finite, so one of the numbers is free.
+ if let Some((i, name)) = (*from..).map(|i| (i, format!("{} {i}", b.name))).find(|(_, n)| !taken.contains(n)) {
+ *from = i.saturating_add(1);
+ taken.insert(name.clone());
+ kept.insert(name.clone());
+ b.name = name;
+ }
+ }
}
/// Store `lib` as the document's brush library.
diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs
index affd2fa6b7..2d5156d162 100644
--- a/crates/brush/src/tests.rs
+++ b/crates/brush/src/tests.rs
@@ -945,6 +945,34 @@ fn library_defaults_store_and_find() {
assert_eq!(unique_name(&lib, "Arrow"), "Arrow 2");
}
+/// A stored library that repeats a name reads with the first brush of that name as stored and each
+/// later one numbered, past every name the library already has; strokes of the name keep drawing
+/// the first brush.
+#[test]
+fn repeated_brush_names_are_numbered_when_read() {
+ let mut d = Document::new(100.0, 100.0);
+ let named = |name: &str, size: f64| Brush { name: name.into(), ..calli(0.0, 100.0, size) };
+ store(&mut d, &[named("Nib", 1.0), named("Nib 2", 2.0), named("Nib", 3.0), named("Other", 4.0), named("Nib", 5.0), named("Nib 2", 6.0)]);
+ let lib = library(&d);
+ let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).collect();
+ assert_eq!(names, ["Nib", "Nib 2", "Nib 3", "Other", "Nib 4", "Nib 2 2"]);
+ let size = |b: &Brush| if let BrushKind::Calligraphic(c) = &b.kind { c.size } else { 0.0 };
+ assert_eq!(lib.iter().map(size).collect::>(), [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
+ assert_eq!(find(&d, "Nib").map(|b| size(&b)), Some(1.0));
+ assert_eq!(find(&d, "Nib 3").map(|b| size(&b)), Some(3.0));
+ // Reading again gives the same names, and the numbered library stored again reads as stored.
+ assert_eq!(library(&d), lib);
+ store(&mut d, &lib);
+ assert_eq!(library(&d), lib);
+ // Many brushes of one name take the numbers in order, past the numbered names the file has.
+ let many: Vec = (0..5000).map(|i| named(if i == 1 { "Nib 3" } else { "Nib" }, 1.0)).collect();
+ store(&mut d, &many);
+ let names: Vec = library(&d).into_iter().map(|b| b.name).collect();
+ assert_eq!(names[..5], ["Nib", "Nib 3", "Nib 2", "Nib 4", "Nib 5"]);
+ assert_eq!(names.last().map(String::as_str), Some("Nib 5000"));
+ assert_eq!(names.iter().collect::>().len(), names.len());
+}
+
#[test]
fn brushes_serde_round_trip() {
let lib = defaults().to_vec();
diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs
index 46dcdb52ba..f4297343a6 100644
--- a/crates/color/src/cms.rs
+++ b/crates/color/src/cms.rs
@@ -2,7 +2,8 @@
//! gamut checks and soft-proof transforms.
//!
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB
-//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
+//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB;
+//! the desktop canvas converts it into the display's own profile last ([`display`]).
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
@@ -10,6 +11,7 @@
//! the profiles [`use_document_profiles`] last put in place of the working spaces;
//! [`crate::Color::to_rgb`] uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`].
+pub mod display;
pub mod generic;
pub mod icc;
pub mod lab;
@@ -531,7 +533,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/cms/display.rs b/crates/color/src/cms/display.rs
new file mode 100644
index 0000000000..03fe00220c
--- /dev/null
+++ b/crates/color/src/cms/display.rs
@@ -0,0 +1,327 @@
+//! The canvas's last step before the screen: the sRGB the canvas draws, converted into a display's
+//! ICC profile (Preferences › Performance › Use Monitor Profile for Canvas).
+//!
+//! The display profile is a destination only. Document colors, proofs, exports and the web build
+//! keep their sRGB output; a soft proof is applied first, then this conversion.
+//!
+//! [`DisplayTransform::from_icc`] accepts an RGB display-class (`mntr`) profile of at most
+//! [`MAX_PROFILE_BYTES`] whose declared length fits the data and from which a transform can be built.
+//! The conversion is relative colorimetric, sampled once into a [`LUT_N`]³ table, so drawing a frame
+//! costs a table lookup per pixel and never builds a transform.
+
+use std::hash::{Hash, Hasher};
+
+use super::{Intent, ProfileKind, icc::IccProfile};
+
+/// The largest display profile read: VectorCraft's own limit. Display profiles are a few kilobytes
+/// (matrix/shaper) to a few hundred (table-based).
+pub const MAX_PROFILE_BYTES: usize = 1 << 20;
+
+/// Grid points per channel of the conversion table.
+const LUT_N: usize = 33;
+
+/// Why a display profile can't be used. The canvas then stays sRGB.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ProfileError {
+ /// More bytes than [`MAX_PROFILE_BYTES`].
+ TooLarge(usize),
+ /// No ICC header (`acsp` signature) or a header cut short.
+ NotIcc,
+ /// The header's length is shorter than a header or longer than the data.
+ BadLength { declared: usize, len: usize },
+ /// The profile class isn't a display's (`mntr`): an input, output or other profile.
+ NotDisplay(String),
+ /// The profile's data color space isn't RGB.
+ NotRgb(String),
+ /// The profile doesn't parse.
+ Unreadable(String),
+ /// No conversion from sRGB into the profile can be built.
+ NoTransform,
+}
+
+impl std::fmt::Display for ProfileError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::TooLarge(n) => write!(f, "the profile is {n} bytes, more than the {MAX_PROFILE_BYTES} a display profile may have"),
+ Self::NotIcc => f.write_str("the file is not an ICC profile"),
+ Self::BadLength { declared, len } => write!(f, "the profile's header gives a length of {declared} bytes for {len} bytes of data"),
+ Self::NotDisplay(class) => write!(f, "the profile's class is `{class}`, not a display profile (`mntr`)"),
+ Self::NotRgb(space) => write!(f, "the profile's color space is `{space}`, not RGB"),
+ Self::Unreadable(e) => write!(f, "the profile can't be read: {e}"),
+ Self::NoTransform => f.write_str("no conversion into the profile can be built"),
+ }
+ }
+}
+
+/// Four header bytes as text, for an error message.
+fn signature(b: &[u8]) -> String {
+ String::from_utf8_lossy(b).trim_end().to_string()
+}
+
+/// sRGB → a display profile, as a table.
+pub struct DisplayTransform {
+ name: String,
+ fingerprint: u64,
+ /// `LUT_N`³ display values, red slowest; `None` when the profile shows sRGB unchanged (every
+ /// grid point within half an 8-bit step of itself).
+ table: Option>,
+}
+
+impl std::fmt::Debug for DisplayTransform {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("DisplayTransform").field("name", &self.name).field("identity", &self.is_identity()).finish()
+ }
+}
+
+impl DisplayTransform {
+ /// Checks `bytes` as a display profile, as [`Self::from_icc`] does, without building the
+ /// conversion table.
+ pub fn check(bytes: &[u8]) -> Result<(), ProfileError> {
+ Self::parse(bytes).map(|_| ())
+ }
+
+ /// Checks `bytes` as a display profile and builds the conversion into it.
+ pub fn from_icc(bytes: &[u8]) -> Result {
+ let (profile, bytes) = Self::parse(bytes)?;
+ let to_display =
+ |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?]));
+ let s = (LUT_N - 1) as f32;
+ let mut table = Vec::with_capacity(LUT_N.pow(3));
+ for r in 0..LUT_N {
+ for g in 0..LUT_N {
+ for b in 0..LUT_N {
+ let rgb = [r as f32 / s, g as f32 / s, b as f32 / s];
+ table.push(to_display(rgb).ok_or(ProfileError::NoTransform)?);
+ }
+ }
+ }
+ let grid = |i: usize| [i / (LUT_N * LUT_N), i / LUT_N % LUT_N, i % LUT_N].map(|v| v as f32 / s);
+ let identity = table.iter().enumerate().all(|(i, v)| v.iter().zip(grid(i)).all(|(a, b)| (a - b).abs() <= 0.5 / 255.0));
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ bytes.hash(&mut h);
+ Ok(Self { name: profile.name.clone(), fingerprint: h.finish(), table: (!identity).then_some(table) })
+ }
+
+ /// The parsed profile and its bytes up to its declared length, when `bytes` is an RGB display
+ /// profile from which a conversion can be built.
+ fn parse(bytes: &[u8]) -> Result<(IccProfile, &[u8]), ProfileError> {
+ if bytes.len() > MAX_PROFILE_BYTES {
+ return Err(ProfileError::TooLarge(bytes.len()));
+ }
+ if bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") {
+ return Err(ProfileError::NotIcc);
+ }
+ let declared = bytes.get(0..4).map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize);
+ if declared < 132 || declared > bytes.len() {
+ return Err(ProfileError::BadLength { declared, len: bytes.len() });
+ }
+ let bytes = &bytes[..declared];
+ if &bytes[12..16] != b"mntr" {
+ return Err(ProfileError::NotDisplay(signature(&bytes[12..16])));
+ }
+ if &bytes[16..20] != b"RGB " {
+ return Err(ProfileError::NotRgb(signature(&bytes[16..20])));
+ }
+ let profile = IccProfile::from_bytes(None, bytes).map_err(|e| ProfileError::Unreadable(e.to_string()))?;
+ if profile.kind != ProfileKind::Rgb {
+ return Err(ProfileError::NotRgb(format!("{:?}", profile.kind)));
+ }
+ let to_display =
+ |rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?]));
+ if to_display([0.5; 3]).is_none() {
+ return Err(ProfileError::NoTransform);
+ }
+ Ok((profile, bytes))
+ }
+
+ /// The profile's description.
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ /// A hash of the profile's bytes, which tells two profiles with the same name apart.
+ pub fn fingerprint(&self) -> u64 {
+ self.fingerprint
+ }
+
+ /// The profile shows sRGB unchanged, so nothing is converted.
+ pub fn is_identity(&self) -> bool {
+ self.table.is_none()
+ }
+
+ /// One sRGB color (0..1) in the display profile.
+ pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
+ let Some(table) = &self.table else { return rgb.map(|v| v.clamp(0.0, 1.0)) };
+ let n = LUT_N;
+ let s = (n - 1) as f32;
+ let p = rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) * s });
+ let i = p.map(|v| (v.floor() as usize).min(n - 2));
+ let t = [p[0] - i[0] as f32, p[1] - i[1] as f32, p[2] - i[2] as f32];
+ let mut out = [0.0f32; 3];
+ for (dr, wr) in [(0, 1.0 - t[0]), (1, t[0])] {
+ for (dg, wg) in [(0, 1.0 - t[1]), (1, t[1])] {
+ for (db, wb) in [(0, 1.0 - t[2]), (1, t[2])] {
+ let w = wr * wg * wb;
+ if w == 0.0 {
+ continue;
+ }
+ let v = table[((i[0] + dr) * n + i[1] + dg) * n + i[2] + db];
+ for c in 0..3 {
+ out[c] += w * v[c];
+ }
+ }
+ }
+ }
+ out.map(|v| v.clamp(0.0, 1.0))
+ }
+
+ /// One 8-bit sRGB color in the display profile.
+ pub fn apply8(&self, rgb: [u8; 3]) -> [u8; 3] {
+ if self.table.is_none() {
+ return rgb;
+ }
+ self.apply(rgb.map(|v| f32::from(v) / 255.0)).map(|v| (v * 255.0).round() as u8)
+ }
+
+ /// Premultiplied RGBA8 pixels, converted in place. Alpha is kept, and fully transparent
+ /// pixels are left as they are.
+ pub fn apply_premultiplied(&self, pixels: &mut [u8]) {
+ if self.table.is_none() {
+ return;
+ }
+ let mut last_in = [0u8; 4];
+ let mut last_out = [0u8; 4];
+ for px in pixels.as_chunks_mut::<4>().0 {
+ let a = px[3];
+ if a == 0 {
+ continue;
+ }
+ if *px == last_in {
+ *px = last_out;
+ continue;
+ }
+ last_in = *px;
+ let un = |c: u8| if a == 255 { c } else { ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 };
+ let out = self.apply8([un(px[0]), un(px[1]), un(px[2])]);
+ for (c, o) in px.iter_mut().zip(out) {
+ *c = if a == 255 { o } else { ((u32::from(o) * u32::from(a) + 127) / 255) as u8 };
+ }
+ last_out = *px;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::cms::{DISPLAY_P3, GENERIC_CMYK, SRGB, icc_bytes};
+
+ /// `name`'s ICC file, marked as a display profile.
+ fn display(name: &str) -> Vec {
+ let mut b = icc_bytes(name).unwrap().to_vec();
+ b[12..16].copy_from_slice(b"mntr");
+ b
+ }
+
+ #[test]
+ fn a_wide_gamut_display_shows_srgb_red_less_saturated_and_srgb_is_left_alone() {
+ let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap();
+ assert!(!p3.is_identity());
+ assert_eq!(p3.name(), DISPLAY_P3);
+ // sRGB red is inside Display P3: its display values have green and blue above 0 and less red.
+ let [r, g, b] = p3.apply8([255, 0, 0]);
+ assert!(r < 245 && g > 40 && b > 10, "{:?}", [r, g, b]);
+ // White and black stay where they are (relative colorimetric).
+ assert_eq!(p3.apply8([255; 3]), [255; 3]);
+ assert_eq!(p3.apply8([0; 3]), [0; 3]);
+ let srgb = DisplayTransform::from_icc(&display(SRGB)).unwrap();
+ assert!(srgb.is_identity());
+ assert_eq!(srgb.apply8([255, 0, 0]), [255, 0, 0]);
+ assert_ne!(srgb.fingerprint(), p3.fingerprint());
+ }
+
+ #[test]
+ fn the_table_matches_the_profile_between_its_grid_points() {
+ let bytes = display(DISPLAY_P3);
+ let p3 = DisplayTransform::from_icc(&bytes).unwrap();
+ let profile = IccProfile::from_bytes(None, &bytes).unwrap();
+ for rgb in [[0.1, 0.7, 0.33], [0.9, 0.05, 0.6], [0.47, 0.47, 0.47], [0.0, 0.2, 1.0]] {
+ let want = profile.from_srgb(rgb, Intent::RelativeColorimetric).unwrap();
+ let got = p3.apply(rgb);
+ for c in 0..3 {
+ assert!((got[c] - want[c]).abs() < 1.0 / 255.0, "{rgb:?}: {got:?} vs {want:?}");
+ }
+ }
+ }
+
+ #[test]
+ fn premultiplied_pixels_keep_their_alpha_and_transparent_ones_stay_empty() {
+ let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap();
+ let mut px = vec![255, 0, 0, 255, 128, 0, 0, 128, 0, 0, 0, 0, 255, 0, 0, 255];
+ p3.apply_premultiplied(&mut px);
+ let opaque = p3.apply8([255, 0, 0]);
+ assert_eq!(&px[0..4], &[opaque[0], opaque[1], opaque[2], 255]);
+ assert_eq!(&px[12..16], &px[0..4], "a repeated pixel converts the same");
+ assert_eq!(px[7], 128);
+ // Half-covered red: the same color at half coverage, within rounding.
+ for c in 0..3 {
+ assert!((i32::from(px[4 + c]) - i32::from(opaque[c]) / 2).abs() <= 1, "{:?}", &px[4..8]);
+ }
+ assert_eq!(&px[8..12], &[0, 0, 0, 0]);
+ }
+
+ #[test]
+ fn profiles_that_are_not_rgb_display_profiles_are_refused() {
+ let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec();
+ assert!(matches!(DisplayTransform::from_icc(&cmyk), Err(ProfileError::NotDisplay(_))));
+ let mut cmyk_display = cmyk.clone();
+ cmyk_display[12..16].copy_from_slice(b"mntr");
+ assert!(matches!(DisplayTransform::from_icc(&cmyk_display), Err(ProfileError::NotRgb(s)) if s == "CMYK"));
+ let mut input = display(DISPLAY_P3);
+ input[12..16].copy_from_slice(b"scnr");
+ assert_eq!(DisplayTransform::from_icc(&input).unwrap_err(), ProfileError::NotDisplay("scnr".into()));
+ }
+
+ #[test]
+ fn malformed_short_and_oversized_data_is_refused() {
+ assert_eq!(DisplayTransform::from_icc(&[]).unwrap_err(), ProfileError::NotIcc);
+ assert_eq!(DisplayTransform::from_icc(&[0; 132]).unwrap_err(), ProfileError::NotIcc);
+ let good = display(DISPLAY_P3);
+ assert_eq!(DisplayTransform::from_icc(&good[..100]).unwrap_err(), ProfileError::NotIcc);
+ let mut long = good.clone();
+ long[0..4].copy_from_slice(&(good.len() as u32 + 1).to_be_bytes());
+ assert_eq!(DisplayTransform::from_icc(&long).unwrap_err(), ProfileError::BadLength { declared: good.len() + 1, len: good.len() });
+ let mut short = good.clone();
+ short[0..4].copy_from_slice(&64u32.to_be_bytes());
+ assert!(matches!(DisplayTransform::from_icc(&short), Err(ProfileError::BadLength { declared: 64, .. })));
+ // Trailing bytes past the declared length are not part of the profile.
+ let mut padded = good.clone();
+ padded.extend([0xAB; 16]);
+ let a = DisplayTransform::from_icc(&padded).unwrap();
+ assert_eq!(a.fingerprint(), DisplayTransform::from_icc(&good).unwrap().fingerprint());
+ let mut garbage_tags = good.clone();
+ for b in &mut garbage_tags[128..] {
+ *b = 0xFF;
+ }
+ assert!(matches!(DisplayTransform::from_icc(&garbage_tags), Err(ProfileError::Unreadable(_) | ProfileError::NoTransform)));
+ let huge = vec![0u8; MAX_PROFILE_BYTES + 1];
+ assert_eq!(DisplayTransform::from_icc(&huge).unwrap_err(), ProfileError::TooLarge(MAX_PROFILE_BYTES + 1));
+ }
+
+ #[test]
+ fn check_accepts_and_refuses_what_from_icc_does() {
+ assert_eq!(DisplayTransform::check(&display(DISPLAY_P3)), Ok(()));
+ assert_eq!(DisplayTransform::check(&display(SRGB)), Ok(()));
+ let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec();
+ assert_eq!(DisplayTransform::check(&cmyk), Err(DisplayTransform::from_icc(&cmyk).unwrap_err()));
+ assert_eq!(DisplayTransform::check(&[1, 2, 3]), Err(ProfileError::NotIcc));
+ }
+
+ #[test]
+ fn nan_and_out_of_range_values_stay_in_range() {
+ let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap();
+ for v in p3.apply([f32::NAN, -1.0, 9.0]) {
+ assert!((0.0..=1.0).contains(&v));
+ }
+ }
+}
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/doc/src/blend.rs b/crates/doc/src/blend.rs
index d170bd606f..d936891514 100644
--- a/crates/doc/src/blend.rs
+++ b/crates/doc/src/blend.rs
@@ -1205,6 +1205,13 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) ->
// Spine
// =====================================================================================
+#[cfg(test)]
+thread_local! {
+ /// How many times [`Spine::new`] has run on this thread, for tests that count how often a path
+ /// is flattened.
+ pub(crate) static SPINES_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
/// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the
/// arc length at each of its anchors.
pub struct Spine {
@@ -1223,6 +1230,8 @@ impl Spine {
/// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when
/// the first one flattens to fewer than two points (so it has no length to walk).
pub fn new(path: &PathData) -> Option {
+ #[cfg(test)]
+ SPINES_BUILT.with(|n| n.set(n.get() + 1));
let sp = path.subpaths.first()?;
let mut pts: Vec = Vec::new();
let mut cum: Vec = Vec::new();
diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs
index 057a9df6a6..418e454b92 100644
--- a/crates/doc/src/document.rs
+++ b/crates/doc/src/document.rs
@@ -78,6 +78,7 @@ impl Document {
units_from_file: _,
index: _,
guide_cache: _,
+ inline_cycles: _,
} = self;
*version == other.version
&& *title == other.title
@@ -262,6 +263,7 @@ impl Document {
units_from_file: false,
index: Default::default(),
guide_cache: Default::default(),
+ inline_cycles: Default::default(),
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs
index c5bf1972d8..a8e3955015 100644
--- a/crates/doc/src/inline.rs
+++ b/crates/doc/src/inline.rs
@@ -2,10 +2,13 @@
//! from the document's symbols, which the text layout needs to size and place it.
use std::borrow::Cow;
+use std::cell::RefCell;
+use std::collections::{HashMap, HashSet};
+use std::sync::{Arc, RwLock, Weak};
use vectorcraft_geom::{Affine, Rect};
-use crate::{Document, NodeId, NodeKind, TextObject};
+use crate::{Document, Node, NodeId, NodeKind, Symbol, TextObject};
/// The document key under which the app records each symbol's natural size (`{name: [w, h]}`).
pub const SYMBOL_SIZES: &str = "symbolSizes";
@@ -39,9 +42,27 @@ impl Document {
usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b)))
}
+ /// The art bounds an inline graphic of the symbol named `name` takes: [`Self::symbol_art_bounds`],
+ /// or `None` (an empty slot) when the symbol's art shows the symbol itself inline, directly or
+ /// through the inline graphics in other symbols' art, so that its size would depend on itself.
+ pub fn inline_art_bounds(&self, name: &str) -> Option {
+ if self.inline_cycles().contains(name) { None } else { self.symbol_art_bounds(name) }
+ }
+
+ /// The names of the symbols that reach themselves through the inline graphics in symbol art,
+ /// found once while the symbols stay as they are.
+ fn inline_cycles(&self) -> Arc> {
+ if let Some(found) = self.inline_cycles.get(&self.symbols) {
+ return found;
+ }
+ let found = Arc::new(InlineGraph::of(self).cyclic);
+ self.inline_cycles.set(&self.symbols, &found);
+ found
+ }
+
/// Does `t` have inline graphics whose resolved bounds differ from the document's symbols?
fn inline_stale(&self, t: &TextObject) -> bool {
- t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol))
+ stale_by(t, &|n| self.inline_art_bounds(n))
}
/// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed
@@ -56,54 +77,421 @@ impl Document {
Cow::Owned(t)
}
- /// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's
- /// cached layout bounds are then cleared).
+ /// Fill in the art bounds of `t`'s inline graphics ([`Self::inline_art_bounds`]); returns
+ /// whether any changed (the text's cached layout is then cleared).
pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool {
- let mut changed = false;
- for r in &mut t.runs {
- if let Some(a) = &mut r.inline {
- let b = self.symbol_art_bounds(&a.symbol);
- if a.bounds != b {
- a.bounds = b;
- changed = true;
- }
- }
- }
- if changed {
- t.cached_bounds = None;
- t.cached_ink = None;
- }
- changed
+ resolve_runs(t, &|n| self.inline_art_bounds(n))
}
- /// Resolve the inline graphics of every text object in the layers (after opening a document
- /// or changing its symbols). Returns the ids of the texts that changed: their cached layout
- /// bounds were cleared and need recomputing.
+ /// Resolve the inline graphics of every text object in the document (after opening a document
+ /// or changing its symbols), without laying the type out again: see
+ /// [`Self::resolve_inline_art_with`].
pub fn resolve_inline_art(&mut self) -> Vec {
- let mut stale = vec![];
- self.walk(|n| {
- if let NodeKind::Text(t) = &n.kind
- && t.runs.iter().any(|r| r.inline.is_some())
- && self.inline_stale(t)
- {
- stale.push(n.id);
- }
- });
+ self.resolve_inline_art_with(&mut |_| {})
+ }
+
+ /// Resolve the inline graphics of every text object in the document: in the layers, the art
+ /// of symbols, graph designs and pattern tiles, and the opacity masks and live objects in
+ /// them. Each text whose inline graphics changed has its cached layout cleared and is passed
+ /// to `layout` to be laid out again. The symbols are resolved first, each after the symbols its
+ /// inline graphics show, so a symbol's art is measured with its type already laid out; a
+ /// symbol that shows itself inline ([`Self::inline_art_bounds`]) is shown as an empty slot.
+ /// Returns the ids of the texts in the layers that changed.
+ pub fn resolve_inline_art_with(&mut self, layout: &mut dyn FnMut(&mut TextObject)) -> Vec {
let mut out = vec![];
- for id in stale {
- let Some(mut t) = self.node(id).and_then(|n| match &n.kind {
- NodeKind::Text(t) => Some(t.clone()),
- _ => None,
- }) else {
- continue;
- };
- if self.resolve_text_inline(&mut t)
- && let Some(n) = self.node_mut(id)
- {
- n.kind = NodeKind::Text(t);
- out.push(id);
+ let mut unused = vec![];
+ let graph = InlineGraph::of(self);
+ let measured = Measured::default();
+ for &i in &graph.order {
+ let mut art = self.symbols[i].art.clone();
+ if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) {
+ self.symbols[i].art = art;
+ }
+ }
+ for i in 0..self.layers.len() {
+ let mut layer = self.layers[i].clone();
+ if resolve_tree(&mut layer, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut out) {
+ self.layers[i] = layer;
+ }
+ }
+ for i in 0..self.graph_designs.len() {
+ let mut art = self.graph_designs[i].art.clone();
+ if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) {
+ self.graph_designs[i].art = art;
+ }
+ }
+ for i in 0..self.patterns.len() {
+ for j in 0..self.patterns[i].art.len() {
+ let mut art = self.patterns[i].art[j].clone();
+ if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) {
+ self.patterns[i].art[j] = art;
+ }
}
}
out
}
}
+
+/// Whether `t` has inline graphics whose bounds differ from what `bounds` gives their symbols.
+fn stale_by(t: &TextObject, bounds: &dyn Fn(&str) -> Option) -> bool {
+ t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != bounds(&a.symbol))
+}
+
+/// Gives `t`'s inline graphics the bounds `bounds` gives their symbols; returns whether any changed
+/// (the text's cached layout is then cleared).
+fn resolve_runs(t: &mut TextObject, bounds: &dyn Fn(&str) -> Option) -> bool {
+ let mut changed = false;
+ for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) {
+ let b = bounds(&a.symbol);
+ if a.bounds != b {
+ a.bounds = b;
+ changed = true;
+ }
+ }
+ if changed {
+ t.cached_bounds = None;
+ t.cached_baselines.clear();
+ t.cached_ink = None;
+ }
+ changed
+}
+
+/// Resolves the inline graphics of the type in tree `n` (opacity mask art included) with
+/// `bounds`, passing each text that changed to `layout` and adding its id to `changed`; returns
+/// whether the tree changed. Each node is visited once, and only the nodes on the way to changed
+/// type are copied.
+fn resolve_tree(
+ n: &mut Arc,
+ bounds: &dyn Fn(&str) -> Option,
+ layout: &mut dyn FnMut(&mut TextObject),
+ changed: &mut Vec,
+) -> bool {
+ let mut copied = false;
+ if matches!(&n.kind, NodeKind::Text(t) if stale_by(t, bounds)) {
+ let node = Arc::make_mut(n);
+ if let NodeKind::Text(t) = &mut node.kind
+ && resolve_runs(t, bounds)
+ {
+ layout(t);
+ changed.push(node.id);
+ }
+ copied = true;
+ }
+ let mask = n.extras().mask.as_deref().and_then(|m| {
+ let mut art = m.art.clone();
+ resolve_tree(&mut art, bounds, layout, changed).then_some(art)
+ });
+ let mut kids = vec![];
+ for (i, c) in n.children().into_iter().flatten().enumerate() {
+ let mut c = c.clone();
+ if resolve_tree(&mut c, bounds, layout, changed) {
+ kids.push((i, c));
+ }
+ }
+ if mask.is_none() && kids.is_empty() {
+ return copied;
+ }
+ let node = Arc::make_mut(n);
+ if let Some(art) = mask
+ && let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_deref_mut())
+ {
+ m.art = art;
+ }
+ if let Some(ch) = node.children_mut() {
+ for (i, c) in kids {
+ ch[i] = c;
+ }
+ }
+ true
+}
+
+/// The art bounds of each symbol, measured once in a pass of [`Document::resolve_inline_art_with`].
+/// A symbol is first asked for after its own type is resolved (the pass goes in
+/// [`InlineGraph::order`]), so its measurement stays current for the rest of the pass.
+#[derive(Default)]
+struct Measured(RefCell>>);
+
+impl Measured {
+ /// The bounds kept for `name`, or `measure`'s, kept for later.
+ fn get(&self, name: &str, measure: impl FnOnce() -> Option) -> Option {
+ if let Some(&b) = self.0.borrow().get(name) {
+ return b;
+ }
+ let b = measure();
+ self.0.borrow_mut().insert(name.to_owned(), b);
+ b
+ }
+}
+
+/// The names of the symbols the inline graphics in tree `n` show (opacity mask art included).
+fn inline_symbols(n: &Node) -> Vec {
+ fn collect(n: &Node, out: &mut Vec) {
+ if let NodeKind::Text(t) = &n.kind {
+ out.extend(t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone())));
+ }
+ if let Some(m) = n.extras().mask.as_deref() {
+ collect(&m.art, out);
+ }
+ for c in n.children().into_iter().flatten() {
+ collect(c, out);
+ }
+ }
+ let mut out = vec![];
+ collect(n, &mut out);
+ out
+}
+
+/// Each symbol name of `d` → the index of the first symbol of that name (the one a name finds).
+fn symbol_index(d: &Document) -> HashMap<&str, usize> {
+ let mut index = HashMap::with_capacity(d.symbols.len());
+ for (i, s) in d.symbols.iter().enumerate() {
+ index.entry(s.name.as_str()).or_insert(i);
+ }
+ index
+}
+
+/// The symbols of a document as their inline graphics depend on each other.
+struct InlineGraph {
+ /// Every symbol index, each after the symbols its art shows inline (except along a cycle).
+ order: Vec,
+ /// The names of the symbols that reach themselves through inline graphics.
+ cyclic: HashSet,
+}
+
+impl InlineGraph {
+ /// The graph of `d`'s symbols: each symbol's art is walked once, and the order and cycles are
+ /// found in time linear in the symbols and their inline references (Tarjan's strongly
+ /// connected components, without recursion).
+ fn of(d: &Document) -> Self {
+ let index = symbol_index(d);
+ let edges: Vec> = d
+ .symbols
+ .iter()
+ .map(|s| {
+ let mut e: Vec = inline_symbols(&s.art).iter().filter_map(|n| index.get(n.as_str()).copied()).collect();
+ e.sort_unstable();
+ e.dedup();
+ e
+ })
+ .collect();
+ let (order, cyclic) = components(&edges);
+ let cyclic = order.iter().filter(|&&i| cyclic[i]).map(|&i| d.symbols[i].name.clone()).collect();
+ Self { order, cyclic }
+ }
+
+ /// [`Document::inline_art_bounds`] with the cycles found in this graph.
+ fn bounds(&self, d: &Document, name: &str) -> Option {
+ if self.cyclic.contains(name) { None } else { d.symbol_art_bounds(name) }
+ }
+}
+
+/// The nodes of the graph `edges` (node → the nodes it depends on), each after the nodes it
+/// depends on except within a cycle, and whether each node is on a cycle (a component of more than
+/// one node, or an edge to itself).
+fn components(edges: &[Vec]) -> (Vec, Vec) {
+ const UNSEEN: usize = usize::MAX;
+ let n = edges.len();
+ let (mut index, mut low, mut on_stack) = (vec![UNSEEN; n], vec![0; n], vec![false; n]);
+ let (mut stack, mut order, mut cyclic) = (vec![], Vec::with_capacity(n), vec![false; n]);
+ let mut next = 0;
+ // The nodes being visited, each with the position of its next edge.
+ let mut calls: Vec<(usize, usize)> = vec![];
+ for root in 0..n {
+ if index[root] != UNSEEN {
+ continue;
+ }
+ index[root] = next;
+ low[root] = next;
+ next += 1;
+ stack.push(root);
+ on_stack[root] = true;
+ calls.push((root, 0));
+ while let Some(top) = calls.last_mut() {
+ let v = top.0;
+ if let Some(&w) = edges[v].get(top.1) {
+ top.1 += 1;
+ if index[w] == UNSEEN {
+ index[w] = next;
+ low[w] = next;
+ next += 1;
+ stack.push(w);
+ on_stack[w] = true;
+ calls.push((w, 0));
+ } else if on_stack[w] {
+ low[v] = low[v].min(index[w]);
+ }
+ continue;
+ }
+ calls.pop();
+ if let Some(&(u, _)) = calls.last() {
+ low[u] = low[u].min(low[v]);
+ }
+ if low[v] == index[v] {
+ let start = order.len();
+ while let Some(w) = stack.pop() {
+ on_stack[w] = false;
+ order.push(w);
+ if w == v {
+ break;
+ }
+ }
+ if order.len() - start > 1 || edges[v].contains(&v) {
+ for &w in &order[start..] {
+ cyclic[w] = true;
+ }
+ }
+ }
+ }
+ }
+ (order, cyclic)
+}
+
+/// The symbols [`Document::inline_cycles`] last found, with the name of each symbol and a weak
+/// pointer to its art. While a weak pointer to an art's allocation is held, nothing else can be at
+/// that address, and the art can't be changed in place (`Arc::get_mut` refuses it and
+/// `Arc::make_mut` moves it), so any edit inside a symbol's art, and any symbol added, removed,
+/// renamed or replaced, gives symbols the cache does not match. Clones of a document share it.
+#[derive(Default)]
+pub(crate) struct InlineCycles(RwLock>>);
+
+/// The name and art of each symbol the cycles were found in, and the names of the symbols on one.
+type Found = (Vec<(String, Weak)>, Arc>);
+
+impl InlineCycles {
+ /// The cached names, when they were found in `symbols`.
+ fn get(&self, symbols: &[Symbol]) -> Option>> {
+ let g = self.0.read().ok()?;
+ let (from, found) = &**g.as_ref()?;
+ let same = from.len() == symbols.len()
+ && from.iter().zip(symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art)));
+ same.then(|| found.clone())
+ }
+
+ /// Keep `found`, found in `symbols`.
+ fn set(&self, symbols: &[Symbol], found: &Arc>) {
+ if let Ok(mut g) = self.0.write() {
+ *g = Some(Arc::new((symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(), found.clone())));
+ }
+ }
+}
+
+impl Clone for InlineCycles {
+ fn clone(&self) -> Self {
+ InlineCycles(RwLock::new(self.0.read().ok().and_then(|g| g.clone())))
+ }
+}
+
+/// A cache, not document content: two documents are equal whatever their caches hold.
+impl PartialEq for InlineCycles {
+ fn eq(&self, _: &Self) -> bool {
+ true
+ }
+}
+
+impl std::fmt::Debug for InlineCycles {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("InlineCycles")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The position of each node in `order`.
+ fn positions(order: &[usize]) -> Vec {
+ let mut at = vec![0; order.len()];
+ for (p, &i) in order.iter().enumerate() {
+ at[i] = p;
+ }
+ at
+ }
+
+ #[test]
+ fn components_put_dependencies_first_and_find_every_cycle_member() {
+ // 0 → 1 → 2 → 0 is a cycle, and 3, reached from 1, joins it through 2.
+ // 4 depends on 0 and is on no cycle; 5 shows itself.
+ let edges = vec![vec![1], vec![2, 3], vec![0], vec![2], vec![0], vec![5]];
+ let (order, cyclic) = components(&edges);
+ let mut sorted = order.clone();
+ sorted.sort_unstable();
+ assert_eq!(sorted, [0, 1, 2, 3, 4, 5]);
+ assert_eq!(cyclic, [true, true, true, true, false, true]);
+ let at = positions(&order);
+ assert!(at[4] > at[0], "{order:?}");
+ // A chain: each node after the one it depends on.
+ let (order, cyclic) = components(&[vec![], vec![0], vec![1]]);
+ assert_eq!(order, [0, 1, 2]);
+ assert!(cyclic.iter().all(|c| !c));
+ }
+
+ /// Type at the bottom of 2,000 nested groups is checked once, not once for each group above it,
+ /// and only the groups on the way to it are copied.
+ #[test]
+ fn resolving_deeply_nested_type_visits_each_node_once() {
+ use std::cell::Cell;
+ use vectorcraft_geom::Point;
+
+ let mut t = TextObject::point(Point::ZERO, "Type", crate::CharStyle::default());
+ t.runs.push(crate::TextRun::new("", crate::CharStyle::default()));
+ t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None });
+ let depth = 2_000;
+ let mut tree = Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(t))));
+ for i in 1..=depth {
+ let side = Arc::new(Node::group(NodeId(depth + i), vec![]));
+ tree = Arc::new(Node::group(NodeId(i), vec![side, tree]));
+ }
+ let before = tree.clone();
+ let asked = Cell::new(0);
+ let r = Rect::new(0.0, 0.0, 10.0, 20.0);
+ let bounds = |_: &str| {
+ asked.set(asked.get() + 1);
+ Some(r)
+ };
+ let (mut laid_out, mut changed) = (0, vec![]);
+ assert!(resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed));
+ assert!(asked.get() <= 2, "bounds asked {} times for one inline graphic", asked.get());
+ assert_eq!(laid_out, 1);
+ assert_eq!(changed, [NodeId(0)]);
+ // The bottom of the new tree holds the resolved type; the side groups are shared.
+ let (mut new, mut old) = (&tree, &before);
+ while let (Some(n), Some(o)) = (new.children(), old.children()) {
+ assert!(Arc::ptr_eq(&n[0], &o[0]));
+ (new, old) = (&n[1], &o[1]);
+ }
+ let NodeKind::Text(t) = &new.kind else { panic!("the type is at the bottom") };
+ assert_eq!(t.runs[1].inline.as_ref().and_then(|a| a.bounds), Some(r));
+ // Nothing left to change: the tree is kept and the type is checked once.
+ asked.set(0);
+ let kept = tree.clone();
+ assert!(!resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed));
+ assert!(Arc::ptr_eq(&tree, &kept));
+ assert_eq!(asked.get(), 1);
+ }
+
+ #[test]
+ fn a_symbol_is_measured_once_in_a_pass() {
+ let measured = Measured::default();
+ let count = std::cell::Cell::new(0);
+ let measure = |b: Option| {
+ count.set(count.get() + 1);
+ b
+ };
+ let r = Some(Rect::new(0.0, 0.0, 1.0, 1.0));
+ for _ in 0..3 {
+ assert_eq!(measured.get("A", || measure(r)), r);
+ assert_eq!(measured.get("Missing", || measure(None)), None);
+ }
+ assert_eq!(count.get(), 2);
+ }
+
+ #[test]
+ fn components_handle_a_long_chain_without_recursion() {
+ let n = 200_000;
+ let edges: Vec> = (0..n).map(|i| if i + 1 < n { vec![i + 1] } else { vec![] }).collect();
+ let (order, cyclic) = components(&edges);
+ assert_eq!(order.first(), Some(&(n - 1)));
+ assert_eq!(order.last(), Some(&0));
+ assert!(cyclic.iter().all(|c| !c));
+ }
+}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 63a2676f77..e6986ad00e 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -362,6 +362,10 @@ pub struct Document {
/// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved.
#[serde(skip)]
guide_cache: document_guides::GuideCache,
+ /// The symbols that show themselves inline ([`Document::inline_art_bounds`]): a cache, never
+ /// saved.
+ #[serde(skip)]
+ inline_cycles: inline::InlineCycles,
}
fn ppi72() -> f64 {
diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs
index 52453fd5a6..20d0623d4d 100644
--- a/crates/doc/src/live.rs
+++ b/crates/doc/src/live.rs
@@ -54,8 +54,8 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation,
use blend_spec::{blend_color_shift, blend_ramp};
pub use envelope::{
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds,
- envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin,
- set_type_ink, smooth_handles, warp_grid,
+ envelope_content_boxes, envelope_content_boxes_with, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of,
+ envelope_overlay, envelope_stroke_margin, set_type_ink, smooth_handles, warp_grid,
};
pub(crate) use expand::max_outset;
pub use expand::{
@@ -1247,4 +1247,60 @@ mod tests {
assert_eq!(m, before);
}
}
+
+ /// The pieces of a mesh-warped image make a knockout group, each knocking out its triangle
+ /// (clipping path or opacity mask art); the group keeps the image's transparency.
+ #[test]
+ fn mesh_warp_image_pieces_knock_out_by_their_triangles() {
+ let im = crate::ImageObject {
+ key: "pic".into(),
+ width: 4,
+ height: 4,
+ xf: Affine::scale(10.0),
+ link: None,
+ placement: Default::default(),
+ pixels: None,
+ };
+ let mut n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone())));
+ n.opacity = 0.5;
+ n.blend = crate::color::BlendMode::Multiply;
+ let same = |p: Point| p;
+ for masked in [false, true] {
+ let g = mesh_warp_image(&n, &im, &same, 2, false, masked);
+ assert_eq!((g.knockout, g.opacity, g.blend, g.isolate), (crate::Knockout::On, 0.5, crate::color::BlendMode::Multiply, false));
+ let NodeKind::Group { children, clip: false } = &g.kind else { panic!("a group") };
+ assert_eq!(children.len(), 8);
+ for c in children {
+ assert!(c.extras().warp_piece);
+ let outline = c.knockout_outline().unwrap_or_else(|| panic!("masked {masked}: an outline"));
+ let NodeKind::Path { path, .. } = &outline.kind else { panic!("a path") };
+ assert_eq!(path.anchor_count(), 3);
+ }
+ }
+ // A clip group or masked image that is not a piece knocks out by what it paints.
+ let mut plain = mesh_warp_image(&n, &im, &same, 1, false, false);
+ let NodeKind::Group { children, .. } = &mut plain.kind else { panic!("a group") };
+ let piece = Arc::make_mut(&mut children[0]);
+ piece.extras_mut().warp_piece = false;
+ assert!(piece.knockout_outline().is_none());
+ }
+
+ /// More cells than the objects live art may make cut no pieces (the count overflowed).
+ #[test]
+ fn mesh_warp_image_past_the_budget_makes_an_empty_group() {
+ let im = crate::ImageObject {
+ key: "pic".into(),
+ width: 4,
+ height: 4,
+ xf: Affine::IDENTITY,
+ link: None,
+ placement: Default::default(),
+ pixels: None,
+ };
+ let n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone())));
+ for cells in [usize::MAX, 1000] {
+ let g = mesh_warp_image(&n, &im, &|p| p, cells, false, false);
+ assert!(matches!(&g.kind, NodeKind::Group { children, .. } if children.is_empty()), "{cells}");
+ }
+ }
}
diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs
index 44dc406c1d..5aa43b0a67 100644
--- a/crates/doc/src/live/envelope.rs
+++ b/crates/doc/src/live/envelope.rs
@@ -149,20 +149,31 @@ impl EnvelopeOptions {
/// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as
/// [`Node::transform`] scales them.
pub fn envelope_content_boxes(content: &[Arc], frame: Affine) -> Option<(Rect, Rect)> {
+ envelope_content_boxes_with(content, frame, false).or_else(|| envelope_content_boxes_with(content, frame, true))
+}
+
+/// [`envelope_content_boxes`] with type that draws nothing given its laid-out box when
+/// `blank_type_box` is set and no box when it is not. A caller that measures the members of an
+/// envelope's contents one at a time passes `true` only when the whole contents have no box with
+/// `false`, so each member is measured as the whole contents measure it.
+pub fn envelope_content_boxes_with(content: &[Arc], frame: Affine, blank_type_box: bool) -> Option<(Rect, Rect)> {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
- let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| {
- (vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame)))
- });
- Some((stroked?, plain?))
+ Some((content_set_box(content, true, to_frame, blank_type_box)?, content_set_box(content, false, to_frame, blank_type_box)?))
}
/// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or
/// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead
/// of its laid-out box. Type keeps its laid-out box while nothing is registered with
-/// [`set_type_ink`], and when it draws nothing.
+/// [`set_type_ink`]. Type that draws nothing adds no box while other content has one; when
+/// nothing else has a box, it keeps its laid-out box.
pub fn envelope_content_bounds(content: &[Arc]) -> Option {
- content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY)))
+ content_set_box(content, false, Affine::IDENTITY, false).or_else(|| content_set_box(content, false, Affine::IDENTITY, true))
+}
+
+/// The union of the boxes [`content_box`] gives each of `content`.
+fn content_set_box(content: &[Arc], stroked: bool, to: Affine, blank_type_box: bool) -> Option {
+ content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, stroked, to, blank_type_box)))
}
/// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by
@@ -367,15 +378,16 @@ fn find_type(n: &Node, typed: &mut std::collections::HashSet) -> bool {
/// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them),
/// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups
/// and layers too. Type is laid out as it is on the page, and its outlines are then taken into
-/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box.
-fn content_box(n: &Arc, stroked: bool, to: Affine) -> Option {
+/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box. Type
+/// that draws nothing has its laid-out box with `blank_type_box`, and no box without it.
+fn content_box(n: &Arc, stroked: bool, to: Affine, blank_type_box: bool) -> Option {
let mut typed = std::collections::HashSet::new();
find_type(n, &mut typed);
- content_box_in(n, stroked, to, &typed)
+ content_box_in(n, stroked, to, &typed, blank_type_box)
}
/// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]).
-fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet) -> Option {
+fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet, blank_type_box: bool) -> Option {
// `n` in the axes of `to`.
let moved = || {
if to == Affine::IDENTITY {
@@ -395,10 +407,13 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect
let ink = match n.projection() {
None => type_ink(n, t, to * t.xf, None),
Some(h) => type_ink(n, t, t.xf, Some((h, to))),
- }
- .flatten();
+ };
let m = moved();
- let Some(ink) = ink else { return own(&m) };
+ let ink = match ink {
+ Some(Some(ink)) => ink,
+ Some(None) if !blank_type_box => return None,
+ _ => return own(&m),
+ };
if !stroked {
return Some(ink);
}
@@ -413,7 +428,13 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`.
let clip = children.first()?;
- content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed)
+ content_box_in(
+ clip,
+ stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)),
+ to,
+ typed,
+ blank_type_box,
+ )
}
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
let layer = matches!(n.kind, NodeKind::Layer { .. });
@@ -424,9 +445,11 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect
let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false });
own_strokes.appearance = n.appearance.clone();
own_strokes.transform(to, true);
- own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed)))
+ own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed, blank_type_box)))
} else {
- members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed)))
+ members
+ .filter(|c| c.visible || !layer)
+ .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed, blank_type_box)))
}
}
_ => own(&moved()),
@@ -649,7 +672,7 @@ impl<'a> EnvelopeMap<'a> {
pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
- let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?;
+ let src = content_set_box(content, false, to_frame, false).or_else(|| content_set_box(content, false, to_frame, true))?;
let src = options.source_box(src);
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
}
@@ -835,14 +858,14 @@ mod tests {
let to = Affine::scale(2.0);
let mut moved = (*group).clone();
moved.transform(to, true);
- let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to)));
+ let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to, true)));
assert!(want.is_some());
- assert_eq!(content_box(&group, true, to), want);
+ assert_eq!(content_box(&group, true, to, true), want);
let mut nested = group;
for i in 0..300 {
nested = Arc::new(Node::group(NodeId(10 + i), vec![nested]));
}
- assert!(content_box(&nested, false, Affine::IDENTITY).is_some());
+ assert!(content_box(&nested, false, Affine::IDENTITY, true).is_some());
}
/// A warp envelope of `style` at `bend` with map `version` around a 200×100 rectangle at
diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs
index b8309f675b..d87d7a28c4 100644
--- a/crates/doc/src/live/expand.rs
+++ b/crates/doc/src/live/expand.rs
@@ -207,9 +207,21 @@ pub fn mesh_warp_cost(cells: usize) -> usize {
/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past
/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the
/// color `n` colorizes a grayscale image with, and `n`'s color mask. The result is a group with `n`'s id, name,
-/// transparency, opacity mask and appearance.
+/// transparency, opacity mask and appearance. It is a knockout group and each piece is a
+/// [`crate::NodeExtras::warp_piece`], so where pieces overlap only the top one draws and a
+/// partly transparent image keeps its own alpha there. More cells than [`MAX_GENERATED`] objects
+/// allow make an empty group.
pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node {
let k = cells.max(1);
+ let mut g = n.clone();
+ g.kind = NodeKind::Group { children: vec![], clip: false };
+ g.knockout = crate::Knockout::On;
+ if let Some(e) = g.extras_if_any() {
+ e.colorize = None;
+ }
+ if mesh_warp_cost(k) > MAX_GENERATED {
+ return g;
+ }
// A grayscale image's fill color colorizes each piece; the group of pieces takes none.
let colorize = n.extras().colorize.clone();
// Each piece shows what the image shows: its color mask's masked pixels.
@@ -243,22 +255,22 @@ pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, c
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
// white opacity mask) instead of its clipping path.
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
- pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))))));
+ let mut piece = image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))));
+ piece.extras_mut().warp_piece = true;
+ pieces.push(Arc::new(piece));
continue;
}
let mut clip = Node::path(n.id, shape, Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
- pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
+ let mut piece = Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true });
+ piece.extras_mut().warp_piece = true;
+ pieces.push(Arc::new(piece));
}
}
}
- let mut g = n.clone();
g.kind = NodeKind::Group { children: pieces, clip: false };
- if let Some(e) = g.extras_if_any() {
- e.colorize = None;
- }
g
}
diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs
index 7b130bf395..e13750457e 100644
--- a/crates/doc/src/node/extras.rs
+++ b/crates/doc/src/node/extras.rs
@@ -75,6 +75,10 @@ pub struct NodeExtras {
/// Image Tools › Color Mask: the colors an image hides while its pixels stay as they are
/// ([`crate::color_mask`]).
pub color_mask: Option>,
+ /// A piece of an image a raster mesh warp cut up ([`crate::live::mesh_warp_image`]): in a
+ /// knockout group its knockout shape is the whole area of its triangle, not only where the
+ /// image is opaque ([`Node::knockout_outline`]).
+ pub warp_piece: bool,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -103,6 +107,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
envelope_edit_margin: None,
pixel_aligned: false,
color_mask: None,
+ warp_piece: false,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs
index 415fc1cad1..2bb655236a 100644
--- a/crates/doc/src/node/ops.rs
+++ b/crates/doc/src/node/ops.rs
@@ -716,6 +716,37 @@ impl Node {
out
}
+ /// As a piece of a mesh-warped image ([`crate::NodeExtras::warp_piece`]), the art whose area is its
+ /// knockout shape at full coverage: the art of its opacity mask (on and not inverted), or the
+ /// clipping path of its clip group. `None` for other objects, whose knockout shape is what they
+ /// paint.
+ pub fn knockout_outline(&self) -> Option<&Arc> {
+ let e = self.extras();
+ if !e.warp_piece {
+ return None;
+ }
+ if let Some(m) = e.mask.as_deref() {
+ return (!m.disabled && !m.invert).then_some(&m.art);
+ }
+ match &self.kind {
+ NodeKind::Group { children, clip: true } => children.first(),
+ _ => None,
+ }
+ }
+
+ /// The image a piece of a mesh-warped image draws ([`Self::knockout_outline`]): the piece itself
+ /// when an opacity mask cuts it, else the image its clip group clips. `None` for other objects.
+ pub fn warp_piece_image(&self) -> Option<&Node> {
+ if !self.extras().warp_piece {
+ return None;
+ }
+ match &self.kind {
+ NodeKind::Image(_) => Some(self),
+ NodeKind::Group { children, clip: true } => children.get(1).map(|c| &**c).filter(|c| matches!(c.kind, NodeKind::Image(_))),
+ _ => None,
+ }
+ }
+
/// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest.
/// A layer whose first child is a ruler guide holds no art, so a guide never clips it.
pub fn clips(&self) -> bool {
diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs
index 335eb6312e..9981514879 100644
--- a/crates/doc/src/node/tests.rs
+++ b/crates/doc/src/node/tests.rs
@@ -23,6 +23,7 @@ fn extras_are_written_flat_in_order() {
e.shape_mode = crate::ShapeMode::Exclude;
e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]);
e.pixel_aligned = true;
+ e.warp_piece = true;
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec);
@@ -69,10 +70,12 @@ fn extras_are_written_flat_in_order() {
"shape_mode",
"envelope_edit_margin",
"pixel_aligned",
+ "warp_piece",
];
assert_eq!(keys, order);
assert_eq!(v["shape_mode"], "exclude");
assert_eq!(v["pixel_aligned"], true);
+ assert_eq!(v["warp_piece"], true);
assert_eq!(serde_json::from_value::(v).unwrap(), n);
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs
index 34d8e99c53..885f9a5853 100644
--- a/crates/doc/src/node/wire.rs
+++ b/crates/doc/src/node/wire.rs
@@ -56,6 +56,7 @@ impl Serialize for Node {
e.envelope_edit_margin.is_none(),
is_default(&e.pixel_aligned),
e.color_mask.is_none(),
+ is_default(&e.warp_piece),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -100,6 +101,7 @@ impl Serialize for Node {
field!("envelope_edit_margin", &e.envelope_edit_margin);
field!("pixel_aligned", &e.pixel_aligned);
field!("color_mask", &e.color_mask);
+ field!("warp_piece", &e.warp_piece);
st.end()
}
}
@@ -245,6 +247,8 @@ pub(super) struct NodeWire {
pixel_aligned: bool,
#[serde(default)]
color_mask: Option>,
+ #[serde(default)]
+ warp_piece: bool,
}
impl From for Node {
@@ -274,6 +278,7 @@ impl From for Node {
envelope_edit_margin: w.envelope_edit_margin,
pixel_aligned: w.pixel_aligned,
color_mask: w.color_mask,
+ warp_piece: w.warp_piece,
};
Node {
id: w.id,
diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs
index 26a11cf733..35de7cce73 100644
--- a/crates/doc/src/objects_on_path.rs
+++ b/crates/doc/src/objects_on_path.rs
@@ -121,19 +121,22 @@ impl ObjectsOnPath {
if a.determinant() < 0.0 && self.rotate != 0.0 {
self.rotate = -self.rotate;
}
- let along = paths.and_then(|(old, new)| {
- let s = path_length(new)?.0 / path_length(old)?.0;
- (s.is_finite() && s > 0.0).then_some(s)
- });
- let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt());
// A move leaves the distances along the path as they are.
let moves = a0 == 1.0 && a1 == 0.0 && a2 == 0.0 && a3 == 1.0;
- if !moves && scale.is_finite() && scale > 0.0 {
- let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v);
- let moved = paths.and_then(|(old, new)| self.moved_along(a, old, new, scale));
- let (spacing, start) = moved.unwrap_or((None, None));
- self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g)));
- self.start = start.unwrap_or_else(|| scaled(self.start));
+ if !moves {
+ let measured = paths.and_then(|(old, new)| Some((Measured::new(old)?, Measured::new(new)?)));
+ let along = measured.as_ref().and_then(|(was, is)| {
+ let s = is.len / was.len;
+ (s.is_finite() && s > 0.0).then_some(s)
+ });
+ let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt());
+ if scale.is_finite() && scale > 0.0 {
+ let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v);
+ let moved = measured.as_ref().and_then(|(was, is)| self.moved_along(a, was, is, scale));
+ let (spacing, start) = moved.unwrap_or((None, None));
+ self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g)));
+ self.start = start.unwrap_or_else(|| scaled(self.start));
+ }
}
let after = tilt(self.rotate);
for m in &mut self.members {
@@ -150,11 +153,10 @@ impl ObjectsOnPath {
/// point on `old`, mapped by `a`, measured along `new` near `scale` times its distance along
/// `old`. Either one is `None` when it cannot be followed that way: a start before or beyond the
/// ends of an open path, or a Space value of 0, one the path has no room for, or one with fewer
- /// than two objects. `None` for a path without length or a start that is not finite.
- fn moved_along(&self, a: Affine, old: &PathData, new: &PathData, scale: f64) -> Option<(Option, Option)> {
- let (was, is) = (Spine::new(old)?, Spine::new(new)?);
- let (len, closed) = path_length(old)?;
- let (new_len, _) = path_length(new)?;
+ /// than two objects. `None` for a start that is not finite.
+ fn moved_along(&self, a: Affine, old: &Measured, new: &Measured, scale: f64) -> Option<(Option, Option)> {
+ let (was, is) = (&old.spine, &new.spine);
+ let (len, closed, new_len) = (old.len, old.closed, new.len);
if !self.start.is_finite() {
return None;
}
@@ -230,10 +232,10 @@ pub fn is_objects_on_path(n: &Node) -> bool {
}
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
-/// (not a guide) whose first subpath has some length.
+/// (not a guide) whose first subpath has a length ([`path_length`]).
pub fn attachable_path(n: &Node) -> Option<&PathData> {
match &n.kind {
- NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
+ NodeKind::Path { path, guide: false, .. } if path_length(path).is_some() => Some(path),
_ => None,
}
}
@@ -292,11 +294,27 @@ pub fn upright_frame(n: &Node) -> Affine {
}
/// The length of `path`'s first subpath and whether it is closed. `None` for a path without
-/// length.
+/// length, or with a length too large to be finite.
pub fn path_length(path: &PathData) -> Option<(f64, bool)> {
- let len = Spine::new(path)?.length();
- let closed = path.subpaths.first().is_some_and(|s| s.closed);
- (len.is_finite() && len > 1e-9).then_some((len, closed))
+ Measured::new(path).map(|m| (m.len, m.closed))
+}
+
+/// A path's first subpath flattened ([`Spine`]), with its length and whether it is closed, so that
+/// one flattening serves both measuring the path and finding points along it.
+struct Measured {
+ spine: Spine,
+ len: f64,
+ closed: bool,
+}
+
+impl Measured {
+ /// `None` for a path without length, or with a length too large to be finite.
+ fn new(path: &PathData) -> Option {
+ let spine = Spine::new(path)?;
+ let len = spine.length();
+ let closed = path.subpaths.first().is_some_and(|s| s.closed);
+ (len.is_finite() && len > 1e-9).then_some(Self { spine, len, closed })
+ }
}
/// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the
@@ -341,18 +359,20 @@ fn wrap(s: f64, len: f64) -> f64 {
/// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them
/// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length.
pub fn stops(path: &PathData, n: usize, spacing: Option, start: f64) -> Option> {
- let spine = Spine::new(path)?;
- let (len, closed) = path_length(path)?;
+ let Measured { spine, len, closed } = Measured::new(path)?;
let (start, gap) = layout(len, closed, n, spacing, start);
Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect())
}
/// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others.
+/// `None` for an `i` of `n` or more, without measuring the path.
pub fn stop(path: &PathData, n: usize, spacing: Option, start: f64, i: usize) -> Option<(Point, f64)> {
- let spine = Spine::new(path)?;
- let (len, closed) = path_length(path)?;
+ if i >= n {
+ return None;
+ }
+ let Measured { spine, len, closed } = Measured::new(path)?;
let (start, gap) = layout(len, closed, n, spacing, start);
- (i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i)))
+ Some(spine.at_length(nth_length(len, closed, start, gap, i)))
}
/// The distance along a path of length `len` (closed or not) to object `i`, from `start` and
@@ -400,11 +420,15 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
let old = rec.clone();
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
let path_id = rec.path;
- let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
+ let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })).cloned() else {
g.extras_mut().objects_on_path = None;
return true;
};
- let path = attachable_path(path).cloned();
+ // `stops` measures the path: on a path without length no object moves.
+ let path = match &path.kind {
+ NodeKind::Path { path, guide: false, .. } => Some(path),
+ _ => None,
+ };
let at: HashMap = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
let mut listed: HashSet = rec.members.iter().map(|m| m.id).collect();
@@ -414,7 +438,7 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
}
}
let mut moved = false;
- if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len(), rec.spacing, rec.start)) {
+ if let Some(stops) = path.and_then(|p| stops(p, rec.members.len(), rec.spacing, rec.start)) {
let turn = tilt(rec.rotate);
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
@@ -709,6 +733,72 @@ mod tests {
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
}
+ /// What `f` returns, and how many times it flattened a path ([`Spine::new`]).
+ fn flattenings(f: impl FnOnce() -> T) -> (T, usize) {
+ crate::blend::SPINES_BUILT.with(|n| n.set(0));
+ let out = f();
+ (out, crate::blend::SPINES_BUILT.with(|n| n.get()))
+ }
+
+ #[test]
+ fn stops_and_stop_flatten_the_path_once() {
+ let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
+ let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
+ for path in [&open, &closed] {
+ let (all, count) = flattenings(|| stops(path, 4, Some(40.0), 25.0));
+ let all = all.unwrap();
+ assert_eq!((all.len(), count), (4, 1));
+ for (i, s) in all.iter().enumerate() {
+ assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, i)), (Some(*s), 1), "object {i}");
+ }
+ assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, 4)), (None, 0), "an index past the last object");
+ assert_eq!(flattenings(|| stop(path, 0, None, 0.0, 0)), (None, 0), "no objects");
+ }
+ let point = shapes::line(Point::ZERO, Point::ZERO);
+ assert!(stops(&point, 2, None, 0.0).is_none() && stop(&point, 2, None, 0.0, 0).is_none());
+ }
+
+ #[test]
+ fn transforming_the_group_flattens_each_path_once() {
+ let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
+ let b = rect(3, Rect::new(20.0, 0.0, 30.0, 10.0));
+ let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
+ let stretch = Affine::scale_non_uniform(2.0, 1.0);
+ let wide = level.transformed(stretch);
+ let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) };
+ let ((), count) = flattenings(|| rec.transform(stretch, Some((&level, &wide))));
+ assert_eq!(count, 2, "the path before the transform and after it");
+ assert!((rec.start - 20.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}");
+ let moved = level.transformed(Affine::translate((5.0, 5.0)));
+ let ((), count) = flattenings(|| rec.transform(Affine::translate((5.0, 5.0)), Some((&level, &moved))));
+ assert_eq!(count, 0, "a move leaves the distances along the path as they are");
+ }
+
+ #[test]
+ fn placing_the_objects_flattens_the_path_once() {
+ let line = Node::path(NodeId(1), shapes::line(Point::new(0.0, 100.0), Point::new(300.0, 100.0)), Default::default());
+ let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)));
+ let mut g = Node::new(NodeId(4), NodeKind::Group { children: vec![Arc::new(line), Arc::new(a.clone()), Arc::new(b.clone())], clip: false });
+ g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0)));
+ let empty = crate::Document::new(1.0, 1.0);
+ let symbols = empty.symbol_boxes();
+ assert_eq!(flattenings(|| place(&mut g, &symbols)), (true, 1));
+ let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x;
+ assert!((x(NodeId(2))).abs() < 1e-9 && (x(NodeId(3)) - 300.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3)));
+ }
+
+ /// A path whose length adds up past the largest finite number cannot be measured, so nothing
+ /// attaches to it.
+ #[test]
+ fn a_path_too_long_to_measure_is_not_attachable() {
+ let huge = Node::path(NodeId(1), shapes::line(Point::new(-1e308, 0.0), Point::new(1e308, 0.0)), Default::default());
+ let NodeKind::Path { path, .. } = &huge.kind else { panic!("a path") };
+ assert!(path_length(path).is_none() && stops(path, 2, None, 0.0).is_none());
+ assert!(attachable_path(&huge).is_none());
+ let square = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
+ assert!(attachable_path(&square).is_some());
+ }
+
#[test]
fn a_group_without_its_path_is_a_plain_group() {
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs
index 0812095826..c451fea7be 100644
--- a/crates/doc/src/pixels.rs
+++ b/crates/doc/src/pixels.rs
@@ -194,6 +194,27 @@ impl ImageBlob {
.collect();
Some((w, h, cover))
}
+
+ /// The pixels as straight RGBA, row by row from the top, with the width and the height;
+ /// `None` when the image can't be decoded within [`MAX_RASTER_ALLOC`].
+ pub fn rgba(&self) -> Option<(u32, u32, Vec)> {
+ let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
+ let (w, h) = img.dimensions();
+ Some((w, h, img.into_raw()))
+ }
+
+ /// The width and height in pixels the image's file says it has; `None` for data that isn't an
+ /// image the decoder reads. Reads only its header.
+ pub fn pixel_size(&self) -> Option<(u32, u32)> {
+ image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()
+ }
+
+ /// Whether the image's file is a PNG that stores palette indices (an indexed-color image).
+ /// Reads only its header.
+ pub fn indexed_png(&self) -> bool {
+ // The signature, then the IHDR chunk: length, type, width, height, bit depth, color type.
+ self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") && self.bytes.get(12..16) == Some(b"IHDR".as_slice()) && self.bytes.get(25) == Some(&3)
+ }
}
impl ImageBlob {
@@ -266,6 +287,20 @@ impl ImageBlob {
Some(out)
}
+ /// Whether any pixel of the image is less than fully opaque. `false` when the image can't be
+ /// decoded. The last image decoded is kept, as for [`Self::sample`].
+ pub fn has_transparency(&self) -> bool {
+ self.decoded().is_some_and(|img| img.pixels().any(|p| p.0[3] < 255))
+ }
+
+ /// The alpha of each pixel, row by row from the top: the width, the height and one byte a
+ /// pixel. `None` when the image can't be decoded. The last image decoded is kept, as for
+ /// [`Self::sample`].
+ pub fn alpha(&self) -> Option<(u32, u32, Vec)> {
+ let img = self.decoded()?;
+ Some((img.width(), img.height(), img.pixels().map(|p| p.0[3]).collect()))
+ }
+
/// The decoded pixels (the last image decoded is kept for the next sample).
fn decoded(&self) -> Option> {
thread_local! {
diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs
index b77f804de7..7e42147443 100644
--- a/crates/effects/src/bake.rs
+++ b/crates/effects/src/bake.rs
@@ -83,15 +83,17 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext
/// The geometry `g` with the own effects of `item` (one of the fills and strokes `ctx` is the
/// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient
-/// moved along with them.
+/// moved along with them and the Transform effects the geometry pass left out hidden
+/// ([`crate::Carry::hide_skipped`]).
fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) {
let (ig, carry) = apply(item_effects(item), g, &ctx.item(item));
let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() };
- if !carry.moves(&ap) {
- return (ig, carried.clone());
+ if carry.moves(&ap) {
+ carry.apply(&mut ap);
}
- carry.apply(&mut ap);
- (ig, ap.items.pop().unwrap_or_else(|| carried.clone()))
+ let mut out = ap.items.pop().unwrap_or_else(|| carried.clone());
+ carry.hide_skipped(out.effects_mut());
+ (ig, out)
}
/// A path node of `kind`'s flavour (compound when the result has several subpaths).
@@ -470,6 +472,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
+ carry.hide_skipped(&mut m.appearance.effects);
// The raster effects of its fills and strokes follow its Transform effects, as on the canvas.
crate::raster::follow_items(&mut m);
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs
index 444e814d86..a3e31f1842 100644
--- a/crates/effects/src/clip.rs
+++ b/crates/effects/src/clip.rs
@@ -1,7 +1,8 @@
//! The region a clip group clips to ([`Node::clip_outline`]) with text outlined by the font engine
//! and several shapes united, so the renderer and the SVG and PDF writers clip alike.
-use vectorcraft_doc::{Appearance, Node, NodeKind};
+use vectorcraft_doc::color::{Color, Paint};
+use vectorcraft_doc::{Appearance, Document, Node, NodeKind};
use vectorcraft_geom::{BezPath, FillRule, PathData};
/// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their
@@ -11,6 +12,209 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
clip.clip_outline(Some(&outline_text), &unite)
}
+/// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque
+/// in `color`, which the SVG and PDF writers put in their knockout masks: for a clip group, a
+/// rectangle clipped by the same clipping path (so a viewer covers the same pixels of its edges as
+/// when it clips the piece), else the path of its opacity mask's art, filled. `None` for other
+/// objects and for a piece with nothing to clip by.
+pub fn knockout_silhouette(n: &Node, color: Color) -> Option {
+ let outline = n.knockout_outline()?;
+ let (bp, rule) = clip_outline(outline)?;
+ let paint = Appearance::basic(Paint::solid(color), Paint::None, 0.0);
+ if n.clips() && n.extras().mask.is_none() {
+ let cover = Node::path(n.id, vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Shape::bounding_box(&bp).inflate(1.0, 1.0)), paint);
+ let children = vec![outline.clone(), std::sync::Arc::new(cover)];
+ return Some(Node::new(n.id, NodeKind::Group { children, clip: true }));
+ }
+ let mut p = Node::path(n.id, PathData::from_bezpath(&bp), paint);
+ if let NodeKind::Path { rule: r, .. } = &mut p.kind {
+ *r = rule;
+ }
+ Some(p)
+}
+
+/// Whether any of `pieces`, pieces of a mesh-warped image ([`Node::warp_piece_image`]), paints less
+/// than opaque anywhere inside its triangle: it or its image is partly transparent, a color mask
+/// clears some of its colors, a colorize paint draws its light pixels partly transparent, or the
+/// pixels of its image (looked up in `doc`) are. Each distinct
+/// image's pixels are scanned once. Pieces of an opaque image overlap without showing it, so the SVG
+/// and PDF writers draw them one over another without masks.
+pub fn warp_pieces_translucent(doc: &Document, pieces: &[&Node]) -> bool {
+ let mut scanned: Vec<*const vectorcraft_doc::ImageBlob> = vec![];
+ for n in pieces {
+ let Some(image) = n.warp_piece_image() else { continue };
+ let NodeKind::Image(im) = &image.kind else { continue };
+ let e = image.extras();
+ if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() {
+ return true;
+ }
+ let Some(blob) = doc.image_blob(im) else { continue };
+ if scanned.contains(&std::ptr::from_ref(blob)) {
+ continue;
+ }
+ scanned.push(std::ptr::from_ref(blob));
+ if blob.has_transparency() {
+ return true;
+ }
+ }
+ false
+}
+
+/// Which overlaps of the pieces of a mesh-warped image ([`Node::warp_piece_image`]) can show
+/// through. Where an upper piece overlaps a lower one only over fully opaque pixels of the image,
+/// the SVG and PDF writers draw the upper piece over the lower one without a knockout mask for it:
+/// a mask there would make the opaque pixels on the upper piece's antialiased edge lighter.
+pub struct WarpSeams {
+ pieces: Vec>,
+ alphas: Vec<(u32, u32, Vec)>,
+}
+
+/// A piece of a mesh-warped image whose pixels decide whether its overlaps show through.
+struct SeamPiece {
+ /// The piece's triangle, in document space.
+ triangle: [vectorcraft_geom::Point; 3],
+ /// Maps document space to the pixels of its image as decoded.
+ to_pixels: vectorcraft_geom::Affine,
+ /// The index of its image's alpha in [`WarpSeams::alphas`].
+ alpha: usize,
+}
+
+/// How far from an overlap, in image pixels, a less than opaque pixel makes the overlap show
+/// through: a smoothed image blends each pixel with its neighbors.
+const SEAM_MARGIN: f64 = 2.0;
+
+impl WarpSeams {
+ /// The overlaps of `pieces` (pieces of a mesh-warped image, in paint order), with the pixels of
+ /// their images looked up in `doc`. Each distinct image is decoded once.
+ pub fn new(doc: &Document, pieces: &[&Node]) -> Self {
+ let mut images: Vec<*const vectorcraft_doc::ImageBlob> = vec![];
+ let mut alphas = vec![];
+ let pieces = pieces
+ .iter()
+ .map(|n| {
+ let image = n.warp_piece_image()?;
+ let NodeKind::Image(im) = &image.kind else { return None };
+ let e = image.extras();
+ if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() {
+ return None;
+ }
+ let triangle = triangle(n.knockout_outline()?)?;
+ let blob = doc.image_blob(im)?;
+ let alpha = match images.iter().position(|b| std::ptr::eq(*b, blob)) {
+ Some(i) => i,
+ None => {
+ alphas.push(blob.alpha()?);
+ images.push(std::ptr::from_ref(blob));
+ alphas.len() - 1
+ }
+ };
+ let (w, h, _) = &alphas[alpha];
+ if im.width == 0 || im.height == 0 || im.xf.determinant().abs() < 1e-12 {
+ return None;
+ }
+ let scale = vectorcraft_geom::Affine::scale_non_uniform(f64::from(*w) / f64::from(im.width), f64::from(*h) / f64::from(im.height));
+ Some(SeamPiece { triangle, to_pixels: scale * im.xf.inverse(), alpha })
+ })
+ .collect();
+ WarpSeams { pieces, alphas }
+ }
+
+ /// Whether piece `above` (an index into the pieces) drawn over piece `below` can show the
+ /// lower piece through where they overlap: either piece or its image is partly transparent, a
+ /// color mask or a colorize paint changes its alpha, or a pixel of either image within
+ /// [`SEAM_MARGIN`] of the overlap is less than opaque. `true` for a piece that is not a
+ /// triangle of an image that can be decoded; `false` when the triangles do not overlap.
+ pub fn shows_through(&self, below: usize, above: usize) -> bool {
+ let (Some(Some(a)), Some(Some(b))) = (self.pieces.get(below), self.pieces.get(above)) else { return true };
+ let overlap = convex_intersection(&a.triangle, &b.triangle);
+ overlap.len() >= 3 && [a, b].into_iter().any(|p| self.translucent_near(p, &overlap))
+ }
+
+ /// Whether a pixel of piece `p`'s image within [`SEAM_MARGIN`] of the convex polygon `overlap`
+ /// (document space) is less than opaque.
+ fn translucent_near(&self, p: &SeamPiece, overlap: &[vectorcraft_geom::Point]) -> bool {
+ let Some((w, h, alpha)) = self.alphas.get(p.alpha) else { return true };
+ let poly: Vec = overlap.iter().map(|q| p.to_pixels * *q).collect();
+ let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
+ for q in &poly {
+ (x0, y0, x1, y1) = (x0.min(q.x), y0.min(q.y), x1.max(q.x), y1.max(q.y));
+ }
+ let span = |lo: f64, hi: f64, n: u32| {
+ let lo = (lo - SEAM_MARGIN).floor().clamp(0.0, f64::from(n)) as u32;
+ let hi = (hi + SEAM_MARGIN).ceil().clamp(0.0, f64::from(n)) as u32;
+ lo..hi
+ };
+ let orientation = signed_area(&poly).signum();
+ for y in span(y0, y1, *h) {
+ for x in span(x0, x1, *w) {
+ let c = vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5);
+ let near = poly.iter().zip(poly.iter().cycle().skip(1)).all(|(s, e)| {
+ let edge = *e - *s;
+ orientation * edge.cross(c - *s) >= -SEAM_MARGIN * edge.hypot()
+ });
+ let i = y as usize * *w as usize + x as usize;
+ if near && alpha.get(i).is_none_or(|a| *a < 255) {
+ return true;
+ }
+ }
+ }
+ false
+ }
+}
+
+/// The three corners of the straight-sided triangle `outline` draws, in document space.
+fn triangle(outline: &Node) -> Option<[vectorcraft_geom::Point; 3]> {
+ let (bp, _) = clip_outline(outline)?;
+ let mut points = vec![];
+ for el in bp.elements() {
+ match el {
+ vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if points.is_empty() || points.last() != Some(p) => {
+ points.push(*p)
+ }
+ vectorcraft_geom::PathEl::MoveTo(_) | vectorcraft_geom::PathEl::LineTo(_) | vectorcraft_geom::PathEl::ClosePath => {}
+ _ => return None,
+ }
+ }
+ if points.len() == 4 && points.first() == points.last() {
+ points.pop();
+ }
+ let [a, b, c] = points.as_slice() else { return None };
+ Some([*a, *b, *c])
+}
+
+/// Twice the signed area of polygon `poly`.
+fn signed_area(poly: &[vectorcraft_geom::Point]) -> f64 {
+ poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.to_vec2().cross(b.to_vec2())).sum()
+}
+
+/// The intersection of convex polygons `subject` and `clip`, as a convex polygon (empty when they
+/// do not overlap).
+fn convex_intersection(subject: &[vectorcraft_geom::Point], clip: &[vectorcraft_geom::Point]) -> Vec {
+ let orientation = signed_area(clip).signum();
+ if orientation == 0.0 {
+ return vec![];
+ }
+ let mut out = subject.to_vec();
+ for (s, e) in clip.iter().zip(clip.iter().cycle().skip(1)) {
+ let edge = *e - *s;
+ let side = |p: vectorcraft_geom::Point| orientation * edge.cross(p - *s);
+ let input = std::mem::take(&mut out);
+ for (p, q) in input.iter().zip(input.iter().cycle().skip(1)) {
+ let (sp, sq) = (side(*p), side(*q));
+ if sp >= 0.0 {
+ out.push(*p);
+ }
+ if (sp >= 0.0) != (sq >= 0.0) {
+ out.push(*p + (*q - *p) * (sp / (sp - sq)));
+ }
+ }
+ if out.is_empty() {
+ break;
+ }
+ }
+ out
+}
+
/// Text as one path of its glyph outlines, in document space.
fn outline_text(n: &Node) -> Option