Merge main into fix/ru-message-rows

This commit is contained in:
dotCooCoo
2026-11-14 07:12:00 -08:00
308 changed files with 19576 additions and 1892 deletions
Generated
+1
View File
@@ -6823,6 +6823,7 @@ dependencies = [
"skrifa 0.47.0",
"svgtypes 0.16.1",
"thiserror 2.0.21",
"unicode-bidi",
"usvg 0.48.1",
"vectorcraft-brush",
"vectorcraft-color",
+2
View File
@@ -124,6 +124,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged.
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged.
- 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged.
+49 -4
View File
@@ -8,23 +8,40 @@
//! Pages that aren't secure (plain `http://` other than localhost) and older browsers have none:
//! heartbeats alone decide there.
#[cfg(target_arch = "wasm32")]
use std::cell::RefCell;
#[cfg(target_arch = "wasm32")]
use std::collections::{BTreeMap, BTreeSet};
#[cfg(target_arch = "wasm32")]
use std::sync::{Arc, Mutex};
#[cfg(target_arch = "wasm32")]
use js_sys::{Function, Promise, Reflect};
#[cfg(target_arch = "wasm32")]
use vectorcraft_engine::cmd::recovery::Hold;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast as _;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsValue;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::closure::Closure;
/// How often the snapshot of the locks held is refreshed (ms).
#[cfg(target_arch = "wasm32")]
const REFRESH_MS: i32 = 10_000;
/// How old a snapshot may be and still be trusted (ms). A lock asked for after the snapshot was
/// taken comes with a heartbeat, which stays fresh for at least three minutes, so anything under
/// that is safe; an older snapshot counts every area as held.
const FRESH_MS: f64 = 60_000.0;
/// Whether a snapshot asked for at `asked` can still be trusted at `now`, both in ms on
/// [`vectorcraft_ui_egui::now_ms`]'s clock: it is at most [`FRESH_MS`] old, and not stamped later
/// than `now`.
fn trusted(asked: f64, now: f64) -> bool {
(0.0..=FRESH_MS).contains(&(now - asked))
}
#[cfg(target_arch = "wasm32")]
thread_local! {
/// What releases each lock this tab holds, by name: it resolves the promise the lock is held
/// by (wasm runs on one thread).
@@ -32,26 +49,31 @@ thread_local! {
}
/// The locks of the page, named `<prefix><area>`.
#[cfg(target_arch = "wasm32")]
#[derive(Clone)]
pub struct WebLocks {
prefix: &'static str,
/// The areas whose locks were held at the last snapshot, and when it was asked for (ms since
/// the epoch). `navigator.locks.query()` is asynchronous, so the snapshot is refreshed every
/// The areas whose locks were held at the last snapshot, and when it was asked for (ms on
/// [`vectorcraft_ui_egui::now_ms`]'s monotonic clock, which a change to the system clock does
/// not move). `navigator.locks.query()` is asynchronous, so the snapshot is refreshed every
/// [`REFRESH_MS`] and read from here.
seen: Arc<Mutex<(f64, BTreeSet<String>)>>,
}
/// `navigator.locks`, if the browser has it here.
#[cfg(target_arch = "wasm32")]
fn manager() -> Option<JsValue> {
let locks = Reflect::get(&web_sys::window()?.navigator(), &"locks".into()).ok()?;
locks.is_object().then_some(locks)
}
/// Method `name` of `obj`.
#[cfg(target_arch = "wasm32")]
fn method(obj: &JsValue, name: &str) -> Option<Function> {
Reflect::get(obj, &name.into()).ok()?.dyn_into().ok()
}
#[cfg(target_arch = "wasm32")]
impl WebLocks {
/// The page's Web Locks with a first snapshot of them, kept fresh from then on; `None` when
/// the browser has none or won't say which are held (heartbeats alone decide then).
@@ -75,7 +97,7 @@ impl WebLocks {
/// Take a new snapshot of the locks held.
async fn refresh(&self) -> Option<()> {
let m = manager()?;
let asked = js_sys::Date::now();
let asked = vectorcraft_ui_egui::now_ms();
let query: Promise = method(&m, "query")?.call0(&m).ok()?.dyn_into().ok()?;
let snapshot = wasm_bindgen_futures::JsFuture::from(query).await.ok()?;
let held = js_sys::Array::from(&Reflect::get(&snapshot, &"held".into()).ok()?);
@@ -91,7 +113,7 @@ impl WebLocks {
/// Does a tab hold `area`'s lock? Yes when the last snapshot is too old to tell.
pub fn held(&self, area: &str) -> bool {
let seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
js_sys::Date::now() - seen.0 > FRESH_MS || seen.1.contains(area)
!trusted(seen.0, vectorcraft_ui_egui::now_ms()) || seen.1.contains(area)
}
/// Hold `area`'s lock until the returned hold is dropped (or the page goes).
@@ -110,8 +132,10 @@ impl WebLocks {
}
/// A lock this tab holds (by name), released when dropped.
#[cfg(target_arch = "wasm32")]
struct LockHold(String);
#[cfg(target_arch = "wasm32")]
impl Drop for LockHold {
fn drop(&mut self) {
if let Some(resolve) = RELEASE.with(|r| r.borrow_mut().remove(&self.0)) {
@@ -120,3 +144,24 @@ impl Drop for LockHold {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_snapshot_is_trusted_for_a_minute() {
assert!(trusted(1_000.0, 1_000.0));
assert!(trusted(1_000.0, 1_000.0 + FRESH_MS));
assert!(!trusted(1_000.0, 1_001.0 + FRESH_MS));
// Before the first snapshot every area counts as held.
assert!(!trusted(f64::NEG_INFINITY, 0.0));
}
#[test]
fn a_snapshot_stamped_later_than_now_is_not_trusted() {
// A clock that went back reads an old snapshot as taken in the future.
assert!(!trusted(5_000_000.0, 1_000.0));
assert!(!trusted(1_001.0, 1_000.0));
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ mod api;
mod fonts;
#[cfg(any(target_arch = "wasm32", test))]
mod keys;
#[cfg(target_arch = "wasm32")]
#[cfg(any(target_arch = "wasm32", test))]
mod locks;
#[cfg(any(target_arch = "wasm32", test))]
mod pencil;
+3 -26
View File
@@ -108,37 +108,14 @@ impl eframe::App for App {
self.app.start_pending_update();
#[cfg(target_os = "macos")]
super::mac_cursor::release();
// Dropping the app can wait for the clipboard handles to let go: the windows leave the
// screen first (`quit_windows`).
#[cfg(not(any(target_os = "macos", windows)))]
hide_before_teardown(self.winit_window.as_deref());
super::quit_windows::before_teardown(self.winit_window.as_deref());
end_before_teardown(self.frames);
}
}
/// The main window as [`hide_before_teardown`] sees it.
#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) trait QuittingWindow {
fn hide(&self);
}
#[cfg(not(any(target_os = "macos", windows)))]
impl QuittingWindow for winit::window::Window {
fn hide(&self) {
self.set_visible(false);
}
}
/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits
/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and
/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland
/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and
/// cannot hide a Wayland window.
#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) fn hide_before_teardown<W: QuittingWindow + ?Sized>(window: Option<&W>) {
if let Some(window) = window {
window.hide();
}
}
impl App {
/// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a
/// focused, shown window whose windows had input waits for the renderer and the next frame,
+315 -27
View File
@@ -11,9 +11,9 @@
//! 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:
//! 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
//! Copy offers SVG (`image/svg+xml`), PNG (`image/png`) and text together, and Paste reads copied
//! files (`text/uri-list`), 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.
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
@@ -172,6 +172,85 @@ fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> {
})
}
/// The most URIs a copied file list may hold, VectorCraft's own limit: a longer list (untrusted
/// data) names no files.
const MAX_URIS: usize = 1024;
/// The local files a `text/uri-list` names, in order ([`file_uri_path`]). Each line holds one URI
/// and a line that starts with `#` is a comment (RFC 2483, section 5); lines end in CRLF or LF.
/// Lines that are not UTF-8 and URIs that don't name a local file are skipped. A list of more than
/// [`MAX_URIS`] URIs names none.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn uri_list_paths(list: &[u8]) -> Vec<PathBuf> {
let uris: Vec<&[u8]> = list
.split(|&b| b == b'\n')
.map(|line| line.strip_suffix(b"\r").unwrap_or(line))
.filter(|line| !line.is_empty() && !line.starts_with(b"#"))
.take(MAX_URIS + 1)
.collect();
if uris.len() > MAX_URIS {
return vec![];
}
uris.into_iter().filter_map(|uri| file_uri_path(std::str::from_utf8(uri).ok()?)).collect()
}
/// The local files GNOME Files' `x-special/gnome-copied-files` names: a first line of `copy` or
/// `cut`, then one URI a line, read as [`uri_list_paths`] reads them. A paste places the files'
/// art either way and leaves the files where they are. No files when the first line is neither.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn gnome_copied_paths(list: &[u8]) -> Vec<PathBuf> {
let (action, uris) = match list.iter().position(|&b| b == b'\n') {
Some(end) => (list.get(..end).unwrap_or_default(), list.get(end + 1..).unwrap_or_default()),
None => (list, &[][..]),
};
match action.strip_suffix(b"\r").unwrap_or(action) {
b"copy" | b"cut" => uri_list_paths(uris),
_ => vec![],
}
}
/// The absolute path a `file:` URI names on this machine: `file:/path`, `file:///path` or
/// `file://localhost/path` (RFC 8089, section 2; scheme and host in any letter case), its percent
/// escapes decoded once (RFC 3986, section 2.1) and a fragment (`#…`) left out. None for another
/// scheme, another host, a relative path, a query (`?…`), a bad escape, an escaped NUL or a path
/// that is not UTF-8 once decoded.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn file_uri_path(uri: &str) -> Option<PathBuf> {
let uri = uri.trim_matches(|c: char| c.is_ascii_whitespace());
let uri = uri.split_once('#').map_or(uri, |(before, _)| before);
if uri.contains('?') {
return None;
}
let scheme = uri.get(..5).filter(|s| s.eq_ignore_ascii_case("file:"))?;
let rest = uri.get(scheme.len()..)?;
let path = match rest.strip_prefix("//") {
Some(authority_and_path) => {
let slash = authority_and_path.find('/')?;
let host = authority_and_path.get(..slash)?;
if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") {
return None;
}
authority_and_path.get(slash..)?
}
None => rest.starts_with('/').then_some(rest)?,
};
let mut bytes = Vec::with_capacity(path.len());
let mut it = path.bytes();
while let Some(b) = it.next() {
if b == b'%' {
let hex = |d: Option<u8>| char::from(d?).to_digit(16);
let (hi, lo) = (hex(it.next())?, hex(it.next())?);
bytes.push(u8::try_from(hi * 16 + lo).ok()?);
} else {
bytes.push(b);
}
}
if bytes.contains(&0) {
return None;
}
String::from_utf8(bytes).ok().map(PathBuf::from)
}
#[cfg(windows)]
mod win {
use std::num::NonZeroU32;
@@ -1094,7 +1173,9 @@ mod portable {
}
}
/// Files copied in a file manager (none where the backend can't read a file list).
/// Files copied in a file manager, read through arboard. The Wayland core protocol reads
/// its file list with the rest of a paste
/// ([`DataClipboard::read`](super::wayland::DataClipboard::read)).
fn file_list(&mut self) -> Vec<PathBuf> {
match self {
Self::Arboard(cb) => cb.read(Request::Files).files(),
@@ -1233,8 +1314,9 @@ mod portable {
}
/// 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.
/// the Wayland core protocol the copied files, else the first of `mimes` the clipboard
/// offers, is read: text here, file lists, 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<ClipboardRead> {
let cb = self.cb(Asker::User).ok()?;
#[cfg(target_os = "linux")]
@@ -1335,9 +1417,9 @@ mod portable {
}
/// 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.
/// core protocol copied files, 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 {
#[cfg(target_os = "macos")]
if super::mac::has(mimes) {
@@ -1784,12 +1866,13 @@ mod portable {
/// 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). 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
/// text under each of [`TEXT_TYPES`]; Paste reads the files a file manager copied
/// ([`FILE_LISTS`]), then 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, file lists, 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. A read or send whose pipe moves no data
/// for [`IDLE_WAIT`] fails and closes the pipe, which frees its worker or thread.
#[cfg(target_os = "linux")]
@@ -1817,11 +1900,17 @@ mod wayland {
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};
use super::{BITMAP, Flavour, PNG, PathBuf, SVG, TEXT, copied_file, gnome_copied_paths, uri_list_paths};
/// 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"];
/// What reads the files a file list names.
type PathsOf = fn(&[u8]) -> Vec<PathBuf>;
/// The types files copied in a file manager are offered under, each with what reads its files,
/// best first: `text/uri-list` (RFC 2483), and GNOME Files' own list, read only when the first
/// names no file.
const FILE_LISTS: [(&str, PathsOf); 2] = [("text/uri-list", uri_list_paths), ("x-special/gnome-copied-files", gnome_copied_paths)];
/// 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;
@@ -1905,6 +1994,35 @@ mod wayland {
Some(first)
}
/// Whether a paste of one of `mimes` can take a copied file: one of them is not text.
fn takes_files(mimes: &[&'static str]) -> bool {
mimes.iter().any(|&m| m != TEXT)
}
/// The first type among `offered` that lists copied files ([`FILE_LISTS`]), in any letter case.
fn file_list_type(offered: &[String]) -> Option<&str> {
FILE_LISTS.iter().find_map(|(list, _)| offered.iter().find(|o| o.eq_ignore_ascii_case(list))).map(String::as_str)
}
/// The first file a file manager copied that pastes as one of `mimes` ([`copied_file`]), from
/// the first of [`FILE_LISTS`] among `offered` that names a file, `read` reading each list.
fn copied(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option<Vec<u8>>) -> Option<Flavour> {
if !takes_files(mimes) {
return None;
}
let paths = FILE_LISTS.iter().find_map(|(list, paths_of)| {
let wire = offered.iter().find(|o| o.eq_ignore_ascii_case(list))?;
Some(paths_of(&read(wire)?)).filter(|paths| !paths.is_empty())
})?;
copied_file(paths, mimes)
}
/// What a paste of one of `mimes` takes: the first copied file that is art ([`copied`]), else
/// what [`pick`] takes.
fn pick_pasted(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option<Vec<u8>>) -> Option<Flavour> {
copied(offered, mimes, &mut read).or_else(|| pick(offered, mimes, read))
}
/// The time `idle` from now; None when that is past the clock's range (no limit).
fn idle_until(idle: Duration) -> Option<Instant> {
Instant::now().checked_add(idle)
@@ -2089,9 +2207,9 @@ mod wayland {
impl Unanswered {
/// 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
/// `limit` after the read was sent: the clipboard read again as [`pick_pasted`] 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<Flavour> {
let left = |now: Instant| (self.sent + limit).saturating_duration_since(now);
@@ -2103,7 +2221,9 @@ mod wayland {
}
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())
pick_pasted(&offered, mimes, |wire| {
wait_for_answer(&self.reads.ask(wire.to_string()).ok()?, LOAD_WAIT.max(left(Instant::now()))).ok()?.ok()
})
}
}
@@ -2136,12 +2256,36 @@ mod wayland {
}
}
/// 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.
/// What a paste of one of `mimes` reads: the files a file manager copied, else the first of
/// `mimes` the clipboard offers. A file list is asked for here and read, with the files,
/// by the work that comes back, which pastes the first file that is art ([`copied`]) and
/// else reads `mimes` as [`pick`] does. 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 the work makes after the first waits until `wait`
/// after the first was sent, [`LOAD_WAIT`] at least.
pub(super) fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option<ClipboardRead> {
if takes_files(mimes) {
let offered = lock(&self.seen).offered.clone();
if let Some(list) = file_list_type(&offered).map(str::to_string) {
let sent = Instant::now();
let answer = self.loads.ask(list.clone()).ok()?;
self.unanswered = None;
let (reads, mimes) = (self.loads.asker(), mimes.to_vec());
return Some(ClipboardRead::Later(Box::new(move || {
let left = || LOAD_WAIT.max((sent + wait).saturating_duration_since(Instant::now()));
// The list asked for here is read from its answer, every other type anew.
let mut first = Some((list, answer));
pick_pasted(&offered, &mimes, |wire| {
let answer = match first.take_if(|(list, _)| list.as_str() == wire) {
Some((_, answer)) => answer,
None => reads.ask(wire.to_string()).ok()?,
};
wait_for_answer(&answer, left()).ok()?.ok()
})
})));
}
}
mimes.iter().enumerate().find_map(|(i, &mime)| {
let wire = self.wire(mime)?;
if mime == TEXT {
@@ -2163,10 +2307,11 @@ mod wayland {
})
}
/// Whether the clipboard offers one of `mimes`; nothing is read.
/// Whether the clipboard offers one of `mimes`, or files a file manager copied when a paste
/// of `mimes` can take a file; 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())
(takes_files(mimes) && file_list_type(&seen.offered).is_some()) || mimes.iter().any(|m| wire_type(m, &seen.offered).is_some())
}
/// Whether the clipboard holds this connection's last Copy.
@@ -2835,6 +2980,85 @@ mod wayland {
let Some(ClipboardRead::Later(work)) = reads.read(&[BITMAP], Duration::from_secs(600)) else { panic!("the PNG is asked for") };
assert!(work().is_none());
}
/// Reads of a clipboard holding `data` by type, answered at once.
fn reads_holding(data: Vec<(String, Vec<u8>)>) -> Reads {
let types: Vec<String> = data.iter().map(|(t, _)| t.clone()).collect();
let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| {
data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.clone()).ok_or_else(|| "not offered".to_string())
})
.unwrap();
let types: Vec<&str> = types.iter().map(String::as_str).collect();
Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&types))))
}
/// The `file:` URI of `path`, its spaces escaped.
fn file_uri(path: &std::path::Path) -> String {
format!("file://{}", path.display().to_string().replace(' ', "%20"))
}
/// What a paste of `mimes` takes from `reads`.
fn paste(reads: &mut Reads, mimes: &[&'static str]) -> Option<(&'static str, Vec<u8>)> {
match reads.read(mimes, Duration::from_secs(600))? {
ClipboardRead::Now(f) => Some((f.mime, f.data)),
ClipboardRead::Later(work) => work().map(|f| (f.mime, f.data)),
}
}
#[test]
fn files_copied_in_a_file_manager_paste_as_their_art() {
use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
let d = tempfile::Builder::new().prefix("vectorcraft-wayland-files-").tempdir().unwrap();
let (notes, red) = (d.path().join("notes.txt"), d.path().join("red image.png"));
let mut png = vec![];
image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
std::fs::write(&notes, "not art").unwrap();
std::fs::write(&red, &png).unwrap();
let list = |paths: &[&std::path::Path]| paths.iter().map(|p| file_uri(p) + "\r\n").collect::<String>().into_bytes();
let held = |types: &[(&str, Vec<u8>)]| reads_holding(types.iter().map(|(t, d)| (t.to_string(), d.clone())).collect());
let text = ("text/plain;charset=utf-8", b"the files' paths".to_vec());
// A file list alone enables Paste and pastes the first file that is art.
let mut reads = held(&[("text/uri-list", list(&[&notes, &red]))]);
assert!(reads.has(&PASTE_ORDER) && reads.has(&[BITMAP]));
assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone())));
// A paste of text alone takes no file.
assert!(!reads.has(&[TEXT]));
assert_eq!(paste(&mut reads, &[TEXT]), None);
// The files come before the other types offered, as on the other platforms; the type in
// any letter case.
let mut reads = held(&[(SVG, b"<svg/>".to_vec()), text.clone(), ("Text/URI-List", list(&[&red]))]);
assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone())));
assert_eq!(paste(&mut reads, &[TEXT]), Some((TEXT, text.1.clone())));
// With no file that is art, the paste takes the other types.
let mut reads = held(&[("text/uri-list", list(&[&notes, &d.path().join("gone.png")])), text.clone()]);
assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((TEXT, text.1.clone())));
let mut reads = held(&[("text/uri-list", b"https://example.com/art.png\r\n".to_vec())]);
assert_eq!(paste(&mut reads, &PASTE_ORDER), None);
// GNOME Files' own list, alone or when the URI list names no local file.
let gnome = [b"copy\n".to_vec(), file_uri(&red).into_bytes()].concat();
let mut reads = held(&[("x-special/gnome-copied-files", gnome.clone())]);
assert!(reads.has(&PASTE_ORDER));
assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone())));
let mut reads = held(&[("text/uri-list", b"file://server/art.png\r\n".to_vec()), ("x-special/gnome-copied-files", gnome)]);
assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone())));
// KDE's cut marker is not a file list.
let mut reads = held(&[("application/x-kde-cutselection", b"1".to_vec())]);
assert!(!reads.has(&PASTE_ORDER));
assert_eq!(paste(&mut reads, &PASTE_ORDER), None);
}
#[test]
fn a_file_list_that_cant_be_read_gives_way_to_the_other_types() {
use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
let offered = offered(&["text/uri-list", "text/plain"]);
let mut asked = vec![];
let f = pick_pasted(&offered, &PASTE_ORDER, |wire| {
asked.push(wire.to_string());
(wire == "text/plain").then(|| b"words".to_vec())
});
assert_eq!(f.map(|f| (f.mime, f.data)), Some((TEXT, b"words".to_vec())));
assert_eq!(asked, ["text/uri-list", "text/plain"]);
}
}
}
@@ -2952,6 +3176,70 @@ mod tests {
assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG));
}
fn uri_paths(list: &[u8]) -> Vec<String> {
uri_list_paths(list).into_iter().map(|p| p.to_string_lossy().into_owned()).collect()
}
#[test]
fn a_uri_list_names_the_local_files_of_its_lines() {
// CRLF or LF, comment lines and blank lines.
assert_eq!(uri_paths(b"# copied\r\nfile:///home/a/one.svg\r\n\r\nfile:///home/a/two.png\n"), ["/home/a/one.svg", "/home/a/two.png"]);
assert_eq!(uri_paths(b"file:///a.svg"), ["/a.svg"]);
// Percent escapes are decoded once; `+` and an escaped `#` are part of the name.
assert_eq!(uri_paths(b"file:///home/a/red%20image.png"), ["/home/a/red image.png"]);
assert_eq!(uri_paths(b"file:///home/a/caf%C3%A9.svg\nfile:///home/a/caf\xC3\xA9.svg"), ["/home/a/café.svg", "/home/a/café.svg"]);
assert_eq!(uri_paths(b"file:///a/%231+2.svg"), ["/a/#1+2.svg"]);
assert_eq!(uri_paths(b"file:///a/100%2525.svg"), ["/a/100%25.svg"]);
// No host, `localhost` in any case, and the short form name this machine; a fragment is
// left out.
assert_eq!(
uri_paths(b"file://localhost/a.svg\nFILE://LocalHost/b.svg\nfile:/c.svg\nfile:///d.svg#page=2"),
["/a.svg", "/b.svg", "/c.svg", "/d.svg"]
);
// Other hosts and schemes, relative paths, queries, bad escapes, an escaped NUL, a name
// that is not UTF-8 once decoded and a line that is not UTF-8 name no file.
for uri in [
&b"file://server/a.svg"[..],
b"https://example.com/a.svg",
b"file:a.svg",
b"file://",
b"file://localhost",
b"file:///a.svg?x=1",
b"file:///a%2",
b"file:///a%zz.svg",
b"file:///a%00b.svg",
b"file:///a%FF.svg",
b"file:///a\xFF.svg",
b"/home/a/one.svg",
b"#file:///a.svg",
] {
assert!(uri_list_paths(uri).is_empty(), "{}", String::from_utf8_lossy(uri));
}
// The files beside a URI that names none are kept.
assert_eq!(uri_paths(b"https://example.com/a.svg\r\nfile:///b.svg\r\n"), ["/b.svg"]);
assert!(uri_list_paths(b"").is_empty());
}
#[test]
fn a_uri_list_longer_than_the_limit_names_no_file() {
assert_eq!(MAX_URIS, 1024);
let list = |n: usize| (0..n).map(|i| format!("# file {i}\r\nfile:///f{i}.svg\r\n")).collect::<String>();
// Comment lines don't count.
assert_eq!(uri_list_paths(list(MAX_URIS).as_bytes()).len(), MAX_URIS);
assert!(uri_list_paths(list(MAX_URIS + 1).as_bytes()).is_empty());
}
#[test]
fn gnome_files_list_names_its_files_after_copy_or_cut() {
let paths = |list: &[u8]| gnome_copied_paths(list).into_iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>();
assert_eq!(paths(b"copy\nfile:///a.svg\nfile:///b%20c.png"), ["/a.svg", "/b c.png"]);
assert_eq!(paths(b"cut\r\nfile:///a.svg\r\n"), ["/a.svg"]);
// Another first line, none, or no files.
for list in [&b"move\nfile:///a.svg"[..], b"file:///a.svg\nfile:///b.svg", b"copy", b"", b"Copy\nfile:///a.svg"] {
assert!(gnome_copied_paths(list).is_empty(), "{}", String::from_utf8_lossy(list));
}
}
/// A DIB of `w`×`h` pixels (top row first when `h` is negative): a `header`-byte header (40,
/// or 124 for a BITMAPV5HEADER with BGRA masks), `extra` (the masks after a 40-byte header, a
/// colour table), then `rows`, each padded to 4 bytes.
+7 -3
View File
@@ -60,6 +60,8 @@ mod portal_request;
mod portal_save;
mod prefs_dir;
mod printing;
#[cfg(not(any(target_os = "macos", windows)))]
mod quit_windows;
#[cfg(not(target_os = "macos"))]
mod single_instance;
#[cfg(windows)]
@@ -90,8 +92,6 @@ mod titlebar;
use app::bring_window_forward;
use app::native_input_hook;
use app::open_or_hold;
#[cfg(all(test, not(any(target_os = "macos", windows))))]
use app::{QuittingWindow, hide_before_teardown};
use host_services::services;
#[cfg(all(test, feature = "wgpu"))]
use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes};
@@ -122,7 +122,7 @@ struct App {
/// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option<window_frame::WindowFrameFn>,
/// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635),
/// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`).
/// and to take the window off screen on quit before the clipboard handles let go (`quit_windows`).
winit_window: Option<std::sync::Arc<winit::window::Window>>,
/// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
pending_maximize: bool,
@@ -418,6 +418,10 @@ fn main() -> std::process::ExitCode {
}));
options
};
// Quitting takes the X11 windows of floating panels and documents off screen with the main
// window, so eframe's windows are recorded as it makes and drops them (`quit_windows`).
#[cfg(target_os = "linux")]
let options = eframe::NativeOptions { viewport_hooks: Some(std::sync::Arc::new(quit_windows::Hooks)), ..options };
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
+6 -5
View File
@@ -242,7 +242,7 @@ fn parent(window: &winit::window::Window) -> (String, Option<()>) {
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
/// makes it.
#[cfg(target_os = "linux")]
mod wayland {
pub(crate) mod wayland {
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::protocol::wl_surface::WlSurface;
@@ -308,9 +308,10 @@ mod wayland {
}
}
/// `window`'s Wayland connection and surface, from its raw handles.
/// `window`'s Wayland connection and surface, from its raw handles. The caller holds `window`
/// while it uses them (`quit_windows` unmaps the main window's surface through them).
#[allow(unsafe_code)]
fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
pub(crate) fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
use wayland_backend::sys::client::{Backend, ObjectId};
use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle};
let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) =
@@ -319,8 +320,8 @@ mod wayland {
return None;
};
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller
// holds it until the export is dropped); a backend made from a foreign display does not
// close it when dropped.
// holds it while it uses the connection, and the export holds it until it is dropped); 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()) };
let conn = Connection::from_backend(backend);
// SAFETY: the pointer is the window's live `wl_surface` proxy on that display.
+280
View File
@@ -0,0 +1,280 @@
//! Takes the app's windows off screen when it quits on Linux and the BSDs, before eframe drops the
//! app. Dropping the app waits up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the
//! clipboard handles to let go, and eframe closes the windows only after that wait.
//!
//! The main window is hidden on X11 (XWayland included). winit cannot hide a Wayland window, so on
//! Linux a Wayland main window is unmapped instead: its surface gets no buffer, and the window stays
//! open with the connection the Wayland clipboard copies and reads on. On Linux the X11 windows of
//! floating panels and documents are recorded as eframe creates and drops them ([`Hooks`]), and
//! quitting unmaps them through an X11 connection of its own. On native Wayland, floating panels and
//! documents are normally drawn inside the main window.
#[cfg(target_os = "linux")]
use std::collections::HashMap;
#[cfg(target_os = "linux")]
use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError};
#[cfg(target_os = "linux")]
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
/// The main window as quitting sees it.
pub(crate) trait QuittingWindow {
/// The window is a Wayland surface.
fn is_wayland(&self) -> bool;
/// Hides an X11 window.
fn hide(&self);
/// Takes a Wayland window off screen and keeps it open.
fn unmap(&self);
}
impl QuittingWindow for winit::window::Window {
#[cfg(target_os = "linux")]
fn is_wayland(&self) -> bool {
matches!(self.window_handle().map(|h| h.as_raw()), Ok(RawWindowHandle::Wayland(_)))
}
/// The BSDs have no Wayland unmap: their windows are hidden through winit.
#[cfg(not(target_os = "linux"))]
fn is_wayland(&self) -> bool {
false
}
fn hide(&self) {
self.set_visible(false);
}
#[cfg(target_os = "linux")]
fn unmap(&self) {
unmap_wayland(self);
}
#[cfg(not(target_os = "linux"))]
fn unmap(&self) {
self.set_visible(false);
}
}
/// Takes the main window and the floating windows `x11` lists off screen: a Wayland main window is
/// unmapped and any other main window hidden, and `unmap_x11` unmaps the floating windows.
pub(crate) fn off_screen<W: QuittingWindow + ?Sized>(main: Option<&W>, x11: &[u32], unmap_x11: impl FnOnce(&[u32])) {
if let Some(window) = main {
if window.is_wayland() {
window.unmap();
} else {
window.hide();
}
}
if !x11.is_empty() {
unmap_x11(x11);
}
}
/// [`off_screen`] with the running app's main window and the floating windows [`Hooks`] recorded.
/// Runs on quit, before eframe drops the app.
pub(crate) fn before_teardown(main: Option<&winit::window::Window>) {
#[cfg(target_os = "linux")]
off_screen(main, &CHILD_WINDOWS.x11(), unmap_x11);
#[cfg(not(target_os = "linux"))]
off_screen(main, &[], |_| {});
}
/// The X11 windows of floating panels and documents, by viewport, while eframe holds them.
#[cfg(target_os = "linux")]
#[derive(Default)]
pub(crate) struct ChildWindows(Mutex<HashMap<egui::ViewportId, u32>>);
#[cfg(target_os = "linux")]
impl ChildWindows {
/// eframe made `viewport`'s window, with `handle`. The main window, and windows of other window
/// systems, are not recorded; a window made again for the same viewport replaces its record.
pub(crate) fn created(&self, viewport: egui::ViewportId, handle: Option<RawWindowHandle>) {
let mut windows = self.lock();
match handle.and_then(x11_window) {
Some(id) if viewport != egui::ViewportId::ROOT => windows.insert(viewport, id),
_ => windows.remove(&viewport),
};
}
/// eframe dropped `viewport`'s window.
pub(crate) fn destroyed(&self, viewport: egui::ViewportId) {
self.lock().remove(&viewport);
}
/// The recorded windows, in id order.
pub(crate) fn x11(&self) -> Vec<u32> {
let mut ids: Vec<u32> = self.lock().values().copied().collect();
ids.sort_unstable();
ids.dedup();
ids
}
fn lock(&self) -> MutexGuard<'_, HashMap<egui::ViewportId, u32>> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// The X11 window id of `handle`, None for another window system.
#[cfg(target_os = "linux")]
fn x11_window(handle: RawWindowHandle) -> Option<u32> {
match handle {
RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok(),
RawWindowHandle::Xcb(h) => Some(h.window.get()),
_ => None,
}
}
/// The running app's floating windows, which [`Hooks`] records.
#[cfg(target_os = "linux")]
static CHILD_WINDOWS: LazyLock<ChildWindows> = LazyLock::new(ChildWindows::default);
/// Records the windows eframe creates and drops in [`CHILD_WINDOWS`]; set as
/// [`eframe::NativeOptions::viewport_hooks`].
#[cfg(target_os = "linux")]
pub(crate) struct Hooks;
#[cfg(target_os = "linux")]
impl eframe::ViewportHooks for Hooks {
fn window_created(&self, viewport: egui::ViewportId, window: &winit::window::Window) {
CHILD_WINDOWS.created(viewport, window.window_handle().ok().map(|h| h.as_raw()));
}
fn window_destroyed(&self, viewport: egui::ViewportId) {
CHILD_WINDOWS.destroyed(viewport);
}
}
/// Unmaps the X11 `windows` through a connection of its own, and waits until the server has done so.
/// An id whose window is gone gets an error, which is ignored.
#[cfg(target_os = "linux")]
fn unmap_x11(windows: &[u32]) {
use x11rb::protocol::xproto::ConnectionExt as _;
let conn = match x11rb::connect(None) {
Ok((conn, _)) => conn,
Err(e) => {
log::warn!("couldn't reach the X server to hide the floating windows on quit: {e}");
return;
}
};
for &window in windows {
if let Ok(cookie) = conn.unmap_window(window) {
cookie.ignore_error();
}
}
// The server answers once it has handled the requests before this one.
if let Ok(cookie) = conn.get_input_focus() {
let _ = cookie.reply();
}
}
/// Attaches no buffer to `window`'s Wayland surface and commits it, which unmaps the window: the
/// compositor stops showing it, and the window and its connection stay open.
#[cfg(target_os = "linux")]
fn unmap_wayland(window: &winit::window::Window) {
let Some((conn, surface)) = super::portal_save::wayland::surface(window) else { return };
surface.attach(None, 0, 0);
surface.commit();
if let Err(e) = conn.flush() {
log::warn!("couldn't reach the compositor to hide the window on quit: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
/// A main window that records what quitting did to it.
struct Window<'a> {
wayland: bool,
done: &'a RefCell<Vec<String>>,
}
impl QuittingWindow for Window<'_> {
fn is_wayland(&self) -> bool {
self.wayland
}
fn hide(&self) {
self.done.borrow_mut().push("hide".into());
}
fn unmap(&self) {
self.done.borrow_mut().push("unmap".into());
}
}
fn quit(wayland: Option<bool>, x11: &[u32]) -> Vec<String> {
let done = RefCell::new(Vec::new());
let window = wayland.map(|wayland| Window { wayland, done: &done });
off_screen(window.as_ref(), x11, |ids| done.borrow_mut().push(format!("unmap x11 {ids:?}")));
done.into_inner()
}
/// winit cannot hide a Wayland window: quitting unmaps it, and never only asks winit to hide it.
#[test]
fn quitting_unmaps_a_wayland_main_window() {
assert_eq!(quit(Some(true), &[]), ["unmap"]);
}
/// An X11 main window (XWayland included) is hidden, and the floating panels' and documents' X11
/// windows are unmapped with it.
#[test]
fn quitting_hides_an_x11_main_window_and_its_floating_windows() {
assert_eq!(quit(Some(false), &[]), ["hide"]);
assert_eq!(quit(Some(false), &[7, 9]), ["hide", "unmap x11 [7, 9]"]);
}
/// Without a main window (none was made), the floating windows still go, and with none at all
/// nothing is done.
#[test]
fn quitting_without_a_main_window() {
assert_eq!(quit(None, &[3]), ["unmap x11 [3]"]);
assert!(quit(None, &[]).is_empty());
}
#[cfg(target_os = "linux")]
fn xlib(id: std::os::raw::c_ulong) -> Option<RawWindowHandle> {
Some(RawWindowHandle::Xlib(winit::raw_window_handle::XlibWindowHandle::new(id)))
}
#[cfg(target_os = "linux")]
fn viewport(name: &str) -> egui::ViewportId {
egui::ViewportId::from_hash_of(name)
}
/// The floating windows eframe made are recorded until it drops them; the main window, which
/// quitting reaches through winit, is not.
#[cfg(target_os = "linux")]
#[test]
fn floating_x11_windows_are_recorded_while_they_exist() {
let windows = ChildWindows::default();
windows.created(egui::ViewportId::ROOT, xlib(1));
windows.created(viewport("Layers"), xlib(9));
let xcb = winit::raw_window_handle::XcbWindowHandle::new(std::num::NonZeroU32::new(5).expect("not zero"));
windows.created(viewport("Untitled-2"), Some(RawWindowHandle::Xcb(xcb)));
windows.created(viewport("Color"), xlib(7));
assert_eq!(windows.x11(), [5, 7, 9]);
windows.destroyed(viewport("Color"));
assert_eq!(windows.x11(), [5, 9]);
// A viewport that eframe has already dropped, or never made, is no error.
windows.destroyed(viewport("Color"));
windows.destroyed(viewport("Swatches"));
assert_eq!(windows.x11(), [5, 9]);
}
/// A window made again for a viewport replaces its record, and one of another window system, or
/// one whose handle could not be read, removes it.
#[cfg(target_os = "linux")]
#[test]
fn a_window_made_again_replaces_its_record() {
let windows = ChildWindows::default();
windows.created(viewport("Layers"), xlib(9));
windows.created(viewport("Layers"), xlib(11));
assert_eq!(windows.x11(), [11]);
let surface = std::ptr::NonNull::<std::ffi::c_void>::dangling();
let wayland = winit::raw_window_handle::WaylandWindowHandle::new(surface);
windows.created(viewport("Layers"), Some(RawWindowHandle::Wayland(wayland)));
assert!(windows.x11().is_empty());
windows.created(viewport("Layers"), xlib(11));
windows.created(viewport("Layers"), None);
assert!(windows.x11().is_empty());
}
}
-18
View File
@@ -515,21 +515,3 @@ fn the_linux_desktop_entry_opens_every_readable_format() {
}
}
}
/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose
/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go.
#[cfg(not(any(target_os = "macos", windows)))]
#[test]
fn quitting_hides_the_window_before_the_clipboards_let_go() {
struct Window(std::cell::Cell<u32>);
impl QuittingWindow for Window {
fn hide(&self) {
self.0.set(self.0.get() + 1);
}
}
let window = Window(std::cell::Cell::new(0));
hide_before_teardown(Some(&window));
assert_eq!(window.0.get(), 1, "the window was not hidden");
// Without a window (none was made), there is nothing to hide.
hide_before_teardown::<Window>(None);
}
+10 -2
View File
@@ -13,6 +13,7 @@
mod calli;
mod colorize;
mod defaults;
pub mod stored_art;
pub mod track;
mod warp;
@@ -351,7 +352,8 @@ pub enum Direction {
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum ArtScale {
/// Keep the art's proportions (length follows the path; width scales with it).
/// Keep the art's proportions (length follows the path; width scales with it, times the
/// stroke weight and the brush width).
Proportional,
/// Stretch the art to the path length.
#[default]
@@ -798,9 +800,15 @@ fn number_repeated_names(lib: &mut [Brush]) {
}
}
/// Store `lib` as the document's brush library.
/// Store `lib` as the document's brush library. When the art of a brush holds images or placed
/// documents, each object in that art whose id is 0 or repeats another object's is given a new
/// id ([`stored_art::StoredArt::persist_ids`]), so the Links panel lists it by the same id until it
/// changes.
pub fn store(doc: &mut Document, lib: &[Brush]) {
doc.unknown.insert(DOC_KEY.into(), serde_json::to_value(lib).unwrap_or_default());
if lib.iter().any(|b| b.art().into_iter().any(stored_art::holds_files)) {
stored_art::StoredArt::persist_ids(doc);
}
}
/// Find a brush by name.
+176
View File
@@ -0,0 +1,176 @@
//! The art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) as stored
//! art of the document ([`Document::mounted_art`]): set on the document while a command looks it up
//! by id or changes it, then written back into the library entries whose art changed. The Links
//! panel finds the images and placed documents in that art this way.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeKind};
use crate::{Brush, BrushKind, DOC_KEY};
/// The brushes of a document's stored library whose art holds images or placed documents.
#[derive(Default)]
pub struct StoredArt {
entries: Vec<Entry>,
/// How many entries the stored library had when it was read.
read_entries: usize,
}
/// A library entry whose brush's art holds images or placed documents.
struct Entry {
/// Its index in the stored library.
slot: usize,
/// The entry as it was read.
raw: Value,
brush: Brush,
/// The brush's art ([`Brush::art`]), each root with the `owner` the Links panel names for it.
art: Vec<(Arc<Node>, Value)>,
}
/// What names each root of [`Brush::art`] among the brush's art: a pattern brush's tile, in the
/// same order; nothing for the one root of a scatter or art brush.
fn tiles(b: &Brush) -> Vec<Option<&'static str>> {
match &b.kind {
BrushKind::Pattern(p) => std::iter::once(Some("side"))
.chain(
[
(p.outer_corner.is_some(), "outerCorner"),
(p.inner_corner.is_some(), "innerCorner"),
(p.start.is_some(), "start"),
(p.end.is_some(), "end"),
]
.into_iter()
.filter(|(held, _)| *held)
.map(|(_, tile)| Some(tile)),
)
.collect(),
_ => vec![None],
}
}
/// Whether `n` or its subtree, opacity-mask art included, holds an image or a placed document.
pub(crate) fn holds_files(n: &Node) -> bool {
let mut found = false;
vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| found |= matches!(c.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)));
found
}
impl StoredArt {
/// The brushes of `doc`'s stored library whose art holds an image or a placed document (opacity
/// masks included). An entry that does not read as a brush is left out, and so is left as it
/// is. A root's owner is
/// `{kind: "brush", name, index, tile?}`, with the name and index the brush has in the library as
/// the Brushes panel lists it ([`crate::library`], a repeated name numbered).
pub fn read(doc: &Document) -> Self {
let Some(stored) = doc.unknown.get(DOC_KEY) else { return Self::default() };
let Some(list) = stored.as_array() else { return Self::default() };
let listed = crate::parse_library(stored);
let parsed = list.iter().enumerate().filter_map(|(slot, raw)| Brush::from_json(raw.clone()).ok().map(|b| (slot, raw, b)));
let mut entries = vec![];
for (index, (slot, raw, brush)) in parsed.enumerate() {
if !brush.art().into_iter().any(holds_files) {
continue;
}
let name = listed.get(index).map_or(brush.name.as_str(), |b| b.name.as_str());
let art: Vec<(Arc<Node>, Value)> = brush
.art()
.into_iter()
.zip(tiles(&brush))
.map(|(n, tile)| {
let mut owner = json!({ "kind": "brush", "name": name, "index": index });
if let Some(tile) = tile {
owner["tile"] = json!(tile);
}
(Arc::new(n.clone()), owner)
})
.collect();
if !art.is_empty() {
entries.push(Entry { slot, raw: raw.clone(), brush, art });
}
}
Self { entries, read_entries: list.len() }
}
/// Gives the objects of the stored brushes' art that holds images or placed documents ids of
/// their own in `doc`'s library, as [`Self::mount`] gives them, and writes the brushes whose
/// ids changed back. Does nothing while art is set on `doc` ([`Document::mounted_art`]).
pub fn persist_ids(doc: &mut Document) {
if !doc.mounted_art.is_empty() {
return;
}
let art = Self::read(doc);
if !art.is_empty() {
art.mount(doc);
art.write_entries(doc);
}
}
/// No stored brush's art holds an image or a placed document.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Sets the art on `doc` as [`Document::mounted_art`], each object with an id of its own
/// ([`Document::number_mounted_art`]: an id another stored object has, or none, is replaced, and
/// the id counter stands above them all).
pub fn mount(&self, doc: &mut Document) {
doc.mounted_art = self.entries.iter().flat_map(|e| e.art.iter().cloned()).collect();
doc.number_mounted_art();
}
/// Takes [`Document::mounted_art`] off `doc` and writes each brush whose art changed (new ids
/// included) back into its library entry: the entry at its index when that entry is as it was
/// read or reads as the brush as it was apart from its name, else the first entry that reads as
/// the brush as it was, else the first that does apart from its name. An entry found apart from
/// its name keeps that name (the library was written again meanwhile, as placing a document's
/// brushes into it does, numbering a repeated name). The other entries are left as
/// they are. Then the art of brushes added meanwhile gets ids of its own
/// ([`Self::persist_ids`]).
pub fn write_back(self, doc: &mut Document) {
let read_entries = self.read_entries;
self.write_entries(doc);
if doc.unknown.get(DOC_KEY).and_then(Value::as_array).map(Vec::len) != Some(read_entries) {
Self::persist_ids(doc);
}
}
/// [`Self::write_back`] without giving ids to the art of brushes added meanwhile.
fn write_entries(self, doc: &mut Document) {
let mut mounted = std::mem::take(&mut doc.mounted_art).into_iter();
let mut changed = vec![];
for e in self.entries {
let now: Vec<Arc<Node>> = mounted.by_ref().take(e.art.len()).map(|(n, _)| n).collect();
if now.len() != e.art.len() || now.iter().zip(&e.art).all(|(a, (b, _))| Arc::ptr_eq(a, b)) {
continue;
}
let mut brush = e.brush.clone();
for (slot, art) in brush.art_mut().into_iter().zip(now) {
*slot = Arc::unwrap_or_clone(art);
}
changed.push((e.slot, e.raw, e.brush, brush));
}
let Some(list) = doc.unknown.get_mut(DOC_KEY).and_then(Value::as_array_mut) else { return };
for (slot, raw, was, mut brush) in changed {
// The entry's name when it reads as `was` apart from its name.
let name_of = |entry: &Value| {
Brush::from_json(entry.clone()).ok().and_then(|mut b| {
let name = std::mem::replace(&mut b.name, was.name.clone());
(b == was).then_some(name)
})
};
let at = match list.get(slot) {
Some(entry) if *entry == raw || name_of(entry).is_some() => Some(slot),
_ => list.iter().position(|e| name_of(e).as_ref() == Some(&was.name)).or_else(|| list.iter().position(|e| name_of(e).is_some())),
};
let Some(entry) = at.and_then(|i| list.get_mut(i)) else { continue };
if let Some(name) = name_of(entry) {
brush.name = name;
}
if let Ok(v) = serde_json::to_value(&brush) {
*entry = v;
}
}
}
}
+197
View File
@@ -141,6 +141,50 @@ fn art_brush_proportional_keeps_aspect() {
assert!(near(bounds(&out).height(), 8.0, 1e-6));
}
/// Scale Proportionately art is scaled across by the stroke weight and the Width, as Stretch art
/// is, on top of the scale the path length gives it.
#[test]
fn art_brush_proportional_grows_with_weight() {
let brush = |width: f64, overlap: bool| {
let mut b = art_brush(rect_art(100.0, 4.0), ArtScale::Proportional);
if let BrushKind::Art(a) = &mut b.kind {
(a.width, a.overlap) = (width, overlap);
}
b
};
let draw = |b: &Brush, bp: &BezPath, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, bp, st);
// 3 pt stroke → 3× wider than at 1 pt (8 pt); the length still follows the path.
let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0)));
assert!(near(b.height(), 24.0, 1e-6) && near(b.x0, 0.0, 1e-6) && near(b.x1, 200.0, 1e-6), "{b:?}");
// The Width (50 %) applies on top of the stroke weight.
let b = bounds(&draw(&brush(50.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0)));
assert!(near(b.height(), 12.0, 1e-6), "{b:?}");
// A path twice as long at twice the weight (as Scale Strokes & Effects at 200 % leaves it):
// 2× from the length and 2× from the weight.
let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 400.0, 0.0), &stroke(2.0)));
assert!(near(b.height(), 32.0, 1e-6), "{b:?}");
// With Overlap, each run between corners is scaled by its own length and by the weight: the
// 100 pt run 4 pt × 3 across, the 50 pt run 2 pt × 3.
let mut bent = BezPath::new();
bent.move_to((0.0, 0.0));
bent.line_to((100.0, 0.0));
bent.line_to((100.0, 50.0));
let out = draw(&brush(100.0, true), &bent, &stroke(3.0));
assert_eq!(out.len(), 2);
let (r0, r1) = (out[0].geometric_bounds().unwrap(), out[1].geometric_bounds().unwrap());
assert!(near(r0.height(), 12.0, 1e-6) && near(r1.width(), 6.0, 1e-6), "{r0:?} {r1:?}");
// A width that follows the pen: 50 % at the lightest pressure, 300 % at the heaviest, times
// the 2 pt weight (4 pt art on a 100 pt path: 4 pt across at 100 %, 1 pt either side).
let mut pen = brush(50.0, false);
if let BrushKind::Art(a) = &mut pen.kind {
(a.width_mode, a.width_max) = (Some(Variation::Pressure), Some(300.0));
}
let ramp = StrokeLayer { width: 2.0, ..pressed(vec![(0.0, 0.0), (1.0, 1.0)]) };
let out = draw(&pen, &line(0.0, 0.0, 100.0, 0.0), &ramp);
assert!(near(half_width(&out, 0.0, 1.0), 2.0, 0.1), "{}", half_width(&out, 0.0, 1.0));
assert!(near(half_width(&out, 99.0, 100.0), 12.0, 0.2), "{}", half_width(&out, 99.0, 100.0));
}
#[test]
fn art_brush_between_guides_keeps_ends() {
// Head (last 20 % of a 100 pt art) keeps its 20 pt length on a 300 pt path.
@@ -973,6 +1017,159 @@ fn repeated_brush_names_are_numbered_when_read() {
assert_eq!(names.iter().collect::<std::collections::HashSet<_>>().len(), names.len());
}
/// Art of `w`×`h` points holding a linked image: a group of [`rect_art`] and the image, both with id 0.
fn linked_art(w: f64, h: f64) -> Node {
let image = Node::new(
NodeId(0),
NodeKind::Image(Box::new(vectorcraft_doc::ImageObject {
key: "k".into(),
width: 4,
height: 4,
xf: Affine::IDENTITY,
link: Some(vectorcraft_doc::LinkInfo::new("/art/a.png")),
placement: Default::default(),
pixels: None,
})),
);
Node::group(NodeId(0), vec![std::sync::Arc::new(rect_art(w, h)), std::sync::Arc::new(image)])
}
/// The art of the stored brushes that holds images mounts on the document as stored art, each tile
/// with the brush, its place in the library and the tile as owner, its objects with ids of their
/// own; writing it back changes only the entries whose art changed, and leaves an entry that does
/// not read as a brush, a brush without art and a brush whose art holds no image as they were stored.
#[test]
fn stored_brush_art_mounts_and_writes_back_only_what_changed() {
use stored_art::StoredArt;
let mut d = Document::new(100.0, 100.0);
let layer_object = d.layers[0].id;
let mut side = linked_art(10.0, 4.0);
side.id = layer_object;
let pattern =
Brush { name: "Frame".into(), kind: BrushKind::Pattern(PatternBrush { side, start: Some(linked_art(4.0, 4.0)), ..Default::default() }) };
let scatter = Brush {
name: "Dots".into(),
kind: BrushKind::Scatter(Scatter { art: linked_art(2.0, 2.0), colorization: Colorization::None, ..Default::default() }),
};
// Written as a file holds it, without the ids `store` gives.
let lib = [calli(0.0, 100.0, 3.0), pattern, scatter, art_brush(rect_art(10.0, 4.0), ArtScale::Stretch)];
d.unknown.insert(DOC_KEY.into(), serde_json::to_value(lib).unwrap());
let odd = serde_json::json!({ "name": "Odd", "type": "unknown" });
if let Some(list) = d.unknown.get_mut(DOC_KEY).and_then(serde_json::Value::as_array_mut) {
list.insert(1, odd.clone());
}
let stored = d.unknown[DOC_KEY].clone();
let art = StoredArt::read(&d);
art.mount(&mut d);
let owners: Vec<&serde_json::Value> = d.mounted_art.iter().map(|(_, o)| o).collect();
assert_eq!(
owners,
[
&serde_json::json!({ "kind": "brush", "name": "Frame", "index": 1, "tile": "side" }),
&serde_json::json!({ "kind": "brush", "name": "Frame", "index": 1, "tile": "start" }),
&serde_json::json!({ "kind": "brush", "name": "Dots", "index": 2 }),
]
);
let ids: Vec<NodeId> = d.mounted_art.iter().map(|(n, _)| n.id).collect();
let mut all = vec![];
d.visit_stored(|n, _| all.push(n.id));
assert!(all.iter().all(|id| id.0 != 0) && !all[1..].contains(&layer_object), "{all:?}");
assert_eq!(all.iter().collect::<std::collections::HashSet<_>>().len(), all.len(), "{all:?}");
// The scatter brush's art changes; the pattern brush's tiles keep the ids mounting gave them.
d.stored_node_mut(ids[2]).unwrap().name = Some("changed".into());
art.write_back(&mut d);
assert!(d.mounted_art.is_empty());
let now = d.unknown[DOC_KEY].as_array().unwrap();
assert_eq!(now.len(), 5);
assert_eq!((&now[0], &now[1], &now[4]), (&stored[0], &odd, &stored[4]), "the entries without images stay as stored");
assert_ne!(now[2], stored[2], "the pattern brush keeps the ids its art was given");
let lib = library(&d);
let Some(BrushKind::Pattern(p)) = lib.iter().find(|b| b.name == "Frame").map(|b| &b.kind) else { panic!("no pattern brush") };
assert_eq!((p.side.id, p.start.as_ref().map(|n| n.id)), (ids[0], Some(ids[1])));
assert_eq!(find(&d, "Dots").unwrap().art()[0].name.as_deref(), Some("changed"));
// Mounting again gives the same ids and changes nothing, so writing back leaves the library as it is.
let again = d.unknown[DOC_KEY].clone();
let art = StoredArt::read(&d);
art.mount(&mut d);
assert_eq!(d.mounted_art.iter().map(|(n, _)| n.id).collect::<Vec<_>>(), ids);
art.write_back(&mut d);
assert_eq!(d.unknown[DOC_KEY], again);
}
/// A brush whose stored name repeats an earlier brush's is named in its art's owner as the Brushes
/// panel lists it, numbered.
#[test]
fn stored_brush_art_owner_names_a_repeated_name_as_listed() {
use stored_art::StoredArt;
let mut d = Document::new(100.0, 100.0);
let scatter = Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: linked_art(2.0, 2.0), ..Default::default() }) };
store(&mut d, &[scatter.clone(), scatter]);
StoredArt::read(&d).mount(&mut d);
let names: Vec<&serde_json::Value> = d.mounted_art.iter().map(|(_, o)| &o["name"]).collect();
assert_eq!(names, ["Dots", "Dots 2"]);
}
/// Storing a library whose brush art holds an image gives that art ids of its own, so mounting it
/// later gives the same ids whatever the document allocated in between; art without images keeps
/// the ids it was stored with.
#[test]
fn storing_brush_art_with_images_keeps_ids_of_its_own() {
use stored_art::StoredArt;
let mut d = Document::new(100.0, 100.0);
let mut art = linked_art(2.0, 2.0);
art.id = d.layers[0].id;
let scatter = Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art, ..Default::default() }) };
store(&mut d, &[scatter, art_brush(rect_art(10.0, 4.0), ArtScale::Stretch)]);
let lib = library(&d);
let stored = lib[0].art()[0].id;
assert_ne!(stored, d.layers[0].id);
assert_eq!(lib[1].art()[0].id, NodeId(0), "art without images is stored as it was");
d.alloc_id();
StoredArt::read(&d).mount(&mut d);
assert_eq!(d.mounted_art[0].0.id, stored);
}
/// When the library is written again while the art is mounted, numbering a repeated name and
/// adding a brush (as placing a document's brushes into it does), writing back changes the entry
/// at its own place that reads as the brush apart from its new name, keeping that name, even when
/// an earlier brush is the same as the one it was, and gives the added brush's art ids of its own.
#[test]
fn brush_art_written_back_finds_its_renamed_entry_and_numbers_added_art() {
use stored_art::StoredArt;
/// `n` with ids from `next` on, in paint order.
fn numbered(mut n: Node, next: &mut u64) -> Node {
n.id = NodeId(*next);
*next += 1;
for c in n.children_mut().into_iter().flatten() {
*c = std::sync::Arc::new(numbered((**c).clone(), next));
}
n
}
let mut d = Document::new(100.0, 100.0);
let scatter =
|n: &str| Brush { name: n.into(), kind: BrushKind::Scatter(Scatter { art: numbered(linked_art(2.0, 2.0), &mut 50), ..Default::default() }) };
// Two brushes the same apart from the ids mounting gives the second.
d.unknown.insert(DOC_KEY.into(), serde_json::to_value([scatter("Dots"), scatter("Dots")]).unwrap());
let art = StoredArt::read(&d);
art.mount(&mut d);
assert_eq!(d.mounted_art[0].0.id, NodeId(50));
let second = d.mounted_art[1].0.id;
// The library is written again: the repeated name numbered, a brush whose art repeats an id added.
let mut added = scatter("New");
if let BrushKind::Scatter(s) = &mut added.kind {
s.art.id = d.layers[0].id;
}
let lib = [library(&d), vec![added]].concat();
d.unknown.insert(DOC_KEY.into(), serde_json::to_value(&lib).unwrap());
assert_eq!(lib[1].name, "Dots 2");
d.stored_node_mut(second).unwrap().name = Some("changed".into());
art.write_back(&mut d);
let lib = library(&d);
assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Dots", "Dots 2", "New"]);
assert_eq!((lib[0].art()[0].name.as_deref(), lib[1].art()[0].name.as_deref()), (None, Some("changed")));
assert_ne!(lib[2].art()[0].id, d.layers[0].id, "the added brush's art has ids of its own");
}
#[test]
fn brushes_serde_round_trip() {
let lib = defaults().to_vec();
+5 -16
View File
@@ -465,17 +465,8 @@ fn art_runs(t: &Track) -> Vec<(f64, f64)> {
bounds.windows(2).map(|w| (w[0], w[1])).collect()
}
/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1, and
/// `width` the width (%) there.
fn art_on_run(
t: &Track,
(s0, s1): (f64, f64),
a: &ArtBrush,
(art, b): (&Node, Rect),
cross: f64,
width: f64,
widen: &dyn Fn(f64) -> f64,
) -> Option<Node> {
/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1.
fn art_on_run(t: &Track, (s0, s1): (f64, f64), a: &ArtBrush, (art, b): (&Node, Rect), cross: f64, widen: &dyn Fn(f64) -> f64) -> Option<Node> {
let l = s1 - s0;
if l <= 1e-9 {
return None;
@@ -483,10 +474,8 @@ fn art_on_run(
let w = b.width();
let (along, cross) = match a.scale {
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
ArtScale::Proportional => {
let k = l / w;
(AlongMap::linear(w, s0, s1), k * width / 100.0)
}
// Scaled across as much as along, times the stroke weight and the width, as the other modes are.
ArtScale::Proportional => (AlongMap::linear(w, s0, s1), l / w * cross),
ArtScale::BetweenGuides { start, end } => {
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
let (g0, g1) = (g0.min(g1), g0.max(g1));
@@ -528,7 +517,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer)
};
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] };
for run in runs {
if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, width, &widen) {
if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, &widen) {
out.push(n);
}
}
+104 -12
View File
@@ -9,13 +9,16 @@
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
//! * The **active settings** are process-wide: the working settings (Edit → Color Settings), with
//! 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`].
//! [`crate::Color::to_rgb`] uses them. [`with_active`] puts other settings in their place on one
//! thread, as for a document drawn through its own profiles ([`for_document`]). Code that needs
//! deterministic numbers builds its own [`Cms`] with [`Cms::new`].
pub mod display;
pub mod generic;
pub mod icc;
pub mod lab;
use std::cell::{Cell, RefCell};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
@@ -910,9 +913,32 @@ static WORKING: RwLock<Option<Arc<Cms>>> = RwLock::new(None);
static RGB_IS_SRGB: AtomicBool = AtomicBool::new(true);
static CMYK_IS_DEVICE: AtomicBool = AtomicBool::new(false);
thread_local! {
/// The settings [`with_active`] put in place of the active settings on this thread.
static SCOPED: RefCell<Option<Arc<Cms>>> = const { RefCell::new(None) };
/// [`SCOPED`]'s flags for the fast paths: [`SCOPED_SET`] while it is set, [`SCOPED_SRGB`] when
/// its RGB space is sRGB, and [`SCOPED_DEVICE`] when its CMYK space is device CMYK.
static SCOPED_FLAGS: Cell<u8> = const { Cell::new(0) };
}
const SCOPED_SET: u8 = 1;
const SCOPED_SRGB: u8 = 2;
const SCOPED_DEVICE: u8 = 4;
/// The active color-management context: the working settings, with the profiles
/// [`use_document_profiles`] last put in place of the working spaces.
/// [`use_document_profiles`] last put in place of the working spaces. Inside [`with_active`] on
/// this thread: the settings it was given.
pub fn active() -> Arc<Cms> {
if SCOPED_FLAGS.with(Cell::get) & SCOPED_SET != 0
&& let Some(c) = SCOPED.with(|s| s.borrow().clone())
{
return c;
}
installed()
}
/// The active settings of the process, as [`use_document_profiles`] or [`set_active`] last put them
/// in place.
fn installed() -> Arc<Cms> {
if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
return c.clone();
}
@@ -954,14 +980,8 @@ pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
/// the active settings changed.
pub fn use_document_profiles(rgb: Option<&str>, cmyk: Option<&str>) -> bool {
let working = working();
let mut st = working.settings.clone();
if let Some(p) = rgb.and_then(profile).filter(|p| p.kind == ProfileKind::Rgb) {
st.rgb = p.name;
}
if let Some(p) = cmyk.and_then(profile).filter(|p| p.kind == ProfileKind::Cmyk) {
st.cmyk = p.name;
}
if st == active().settings {
let st = document_settings(&working.settings, rgb, cmyk);
if st == installed().settings {
return false;
}
if st == working.settings {
@@ -976,6 +996,72 @@ pub fn use_document_profiles(rgb: Option<&str>, cmyk: Option<&str>) -> bool {
true
}
/// The working settings `working` with the profiles a document is assigned (`None`: none) in place
/// of the working spaces, as [`use_document_profiles`] takes them.
fn document_settings(working: &ColorSettings, rgb: Option<&str>, cmyk: Option<&str>) -> ColorSettings {
let mut st = working.clone();
if let Some(p) = rgb.and_then(profile).filter(|p| p.kind == ProfileKind::Rgb) {
st.rgb = p.name;
}
if let Some(p) = cmyk.and_then(profile).filter(|p| p.kind == ProfileKind::Cmyk) {
st.cmyk = p.name;
}
st
}
/// How many contexts [`for_document`] keeps.
const DOCUMENT_CONTEXTS: usize = 8;
/// The color-management context a document assigned the profiles `rgb` and `cmyk` (`None`: none)
/// draws through: the one [`use_document_profiles`] would make active for it. A context that is the
/// active or the working one is that one; the others are kept for the same working settings and
/// loaded profiles, so the same document gets the same context again. A profile that cannot be
/// loaded gives the working settings.
pub fn for_document(rgb: Option<&str>, cmyk: Option<&str>) -> Arc<Cms> {
static KEPT: Mutex<Vec<(u64, Arc<Cms>)>> = Mutex::new(Vec::new());
let working = working();
let st = document_settings(&working.settings, rgb, cmyk);
let installed = installed();
if st == installed.settings {
return installed;
}
if st == working.settings {
return working;
}
let generation = profiles_generation();
let mut kept = KEPT.lock().unwrap_or_else(|e| e.into_inner());
kept.retain(|(g, c)| *g == generation && c.working.as_ref().is_some_and(|w| Arc::ptr_eq(w, &working)));
if let Some((_, c)) = kept.iter().find(|(_, c)| c.settings == st) {
return c.clone();
}
let Ok(mut cms) = Cms::new(&st) else { return working };
cms.working = Some(working);
let cms = Arc::new(cms);
if kept.len() >= DOCUMENT_CONTEXTS {
kept.remove(0);
}
kept.push((generation, cms.clone()));
cms
}
/// Run `f` with `cms` as the active settings on this thread ([`active`], [`crate::Color::to_rgb`]
/// and the conversions that read them), as when drawing a document other than the front one
/// through its own profiles. Other threads, and this thread after `f`, keep the settings they had.
pub fn with_active<R>(cms: Arc<Cms>, f: impl FnOnce() -> R) -> R {
/// Puts back the settings this thread had before, when `f` returns or unwinds.
struct Restore(Option<Arc<Cms>>, u8);
impl Drop for Restore {
fn drop(&mut self) {
SCOPED.with(|s| *s.borrow_mut() = self.0.take());
SCOPED_FLAGS.with(|c| c.set(self.1));
}
}
let flags = SCOPED_SET | if cms.rgb_is_srgb() { SCOPED_SRGB } else { 0 } | if matches!(cms.cmyk, CmykSpace::Device) { SCOPED_DEVICE } else { 0 };
let before = SCOPED.with(|s| s.borrow_mut().replace(cms));
let _restore = Restore(before, SCOPED_FLAGS.with(|c| c.replace(flags)));
f()
}
fn install(cms: Arc<Cms>) {
RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed);
CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed);
@@ -984,10 +1070,16 @@ fn install(cms: Arc<Cms>) {
/// Fast path for [`Color::to_rgb`]: RGB colours need no conversion while the working space is sRGB.
pub(crate) fn rgb_is_srgb() -> bool {
RGB_IS_SRGB.load(Ordering::Relaxed)
match SCOPED_FLAGS.with(Cell::get) {
f if f & SCOPED_SET != 0 => f & SCOPED_SRGB != 0,
_ => RGB_IS_SRGB.load(Ordering::Relaxed),
}
}
pub(crate) fn cmyk_is_device() -> bool {
CMYK_IS_DEVICE.load(Ordering::Relaxed)
match SCOPED_FLAGS.with(Cell::get) {
f if f & SCOPED_SET != 0 => f & SCOPED_DEVICE != 0,
_ => CMYK_IS_DEVICE.load(Ordering::Relaxed),
}
}
#[cfg(test)]
+39
View File
@@ -403,3 +403,42 @@ fn lab_colours_round_trip_through_the_cms() {
let Color::Gray { k } = c.convert(&Color::lab(50.0, 0.0, 0.0), Model::Gray, ri) else { panic!("Gray") };
assert!((k - 0.53).abs() < 0.01, "{k}");
}
#[test]
fn with_active_puts_settings_in_place_on_this_thread_only() {
let p3 = Arc::new(Cms::new(&ColorSettings { rgb: DISPLAY_P3.into(), cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap());
let (rgb, ink) = (Color::Rgb { r: 0.2, g: 0.6, b: 0.4 }, Color::Cmyk { c: 0.2, m: 0.3, y: 0.4, k: 0.1 });
let before = (rgb.to_rgb(), ink.to_rgb());
let (inside, ink_inside, other_thread, nested) = with_active(p3.clone(), || {
let other = std::thread::spawn(move || rgb.to_rgb()).join().unwrap();
let nested = with_active(Arc::new(cms()), || rgb.to_rgb());
(rgb.to_rgb(), ink.to_rgb(), other, nested)
});
assert_eq!(inside, p3.display_rgb(&rgb), "RGB is drawn through the settings given");
assert_ne!(inside, before.0);
assert_eq!(ink_inside, naive_cmyk_to_rgb([0.2, 0.3, 0.4, 0.1]), "CMYK takes the device formula of the settings given");
assert_eq!(other_thread, before.0, "another thread keeps the active settings");
assert_eq!(nested, before.0, "an inner call puts its own settings in place");
assert_eq!((rgb.to_rgb(), ink.to_rgb()), before, "the active settings come back after");
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| with_active(p3.clone(), || panic!("unwinds"))));
assert!(unwound.is_err());
assert_eq!((rgb.to_rgb(), ink.to_rgb()), before, "the active settings come back after a panic");
}
#[test]
fn a_document_gets_the_same_context_for_the_same_profiles() {
// Other tests load profiles meanwhile, and a load makes the contexts again: try until one pair
// of calls sees none.
let same = (0..100).any(|_| {
let generation = profiles_generation();
let (a, b) = (for_document(Some(DISPLAY_P3), None), for_document(Some(DISPLAY_P3), None));
profiles_generation() == generation && Arc::ptr_eq(&a, &b)
});
assert!(same, "the same profiles give the same context");
let p3 = for_document(Some(DISPLAY_P3), None);
assert_eq!(p3.settings().rgb, DISPLAY_P3);
assert_eq!(p3.settings().cmyk, working_settings().cmyk, "the working CMYK space stays");
assert_eq!(for_document(None, None).settings(), &working_settings(), "no profiles: the working settings");
let unknown = for_document(Some("no such profile"), Some(SRGB));
assert_eq!(unknown.settings(), &working_settings(), "an unknown profile or one of the other model leaves the working space");
}
+43
View File
@@ -547,6 +547,49 @@ impl Gradient {
None => (Color::BLACK, 1.0),
}
}
/// Which of its stops color some part of it that draws, by the stretches [`Self::sample`]
/// mixes in, for stops in any order: the pair of stop `k` and stop `k + 1` covers the offsets
/// above every offset of stops `0..=k` up to stop `k + 1`'s, and both its stops count when that
/// stretch has positive width and either stop is above opacity 0. The first stop also counts
/// when it is above opacity 0, since it colors the offsets up to its own, and so does the last
/// stop, since it colors the offsets above every stop's. Any other stop, opaque or not, counts
/// only through a pair: a stop out of order whose pairs have no width (another stop before it
/// reaches past both ends) colors nothing. A freeform gradient draws from its points, so each of
/// its stops counts while any stop is above opacity 0.
pub fn drawn_stops(&self) -> Vec<bool> {
self.stops_with_color(|s| s.opacity > 0.0)
}
/// [`Self::drawn_stops`] with every stop taken as opaque, for output that leaves stop opacity
/// out: the stops that color a stretch of positive width, and the first and last stops.
pub fn reached_stops(&self) -> Vec<bool> {
self.stops_with_color(|_| true)
}
/// [`Self::drawn_stops`] with `shows` telling whether a stop's opacity shows its color.
fn stops_with_color(&self, shows: impl Fn(&GradientStop) -> bool) -> Vec<bool> {
let stops = &self.stops;
if self.kind == GradientKind::Freeform {
let any = stops.iter().any(&shows);
return vec![any; stops.len()];
}
let mut drawn = vec![false; stops.len()];
if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) {
*d = shows(first);
}
if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) {
*d |= shows(last);
}
// `f32::max` skips a NaN offset, which the sampler never picks either.
let mut reach = f32::NEG_INFINITY;
for k in 0..stops.len().saturating_sub(1) {
reach = reach.max(stops[k].offset);
let (a, b) = (&stops[k], &stops[k + 1]);
if b.offset > reach && (shows(a) || shows(b)) {
drawn[k] = true;
drawn[k + 1] = true;
}
}
drawn
}
/// [`Self::sample_with`] at each of `ts`, which must not decrease. Each search starts where
/// the one before it stopped, so the work is the samples plus the stops, not their product.
pub fn sample_increasing_with(&self, ts: impl IntoIterator<Item = f32>, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> {
+63
View File
@@ -66,6 +66,69 @@ fn stop_reach_matches_scanning_the_stops() {
}
}
/// A gradient's stops color what draws where they color a stretch that draws, for stops in any
/// order: the sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it,
/// so an out-of-order stop's pair can have no width.
#[test]
fn drawn_stops_follow_the_sampled_stretches() {
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// In order: the clear stop next to the opaque one, not the one after it.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, true, false]);
// Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with
// the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8.
assert_eq!(g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)]).drawn_stops(), vec![false, false, true, true]);
// An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the
// pairs next to the stop at 0.3 have no width and it colors nothing.
let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(out_of_order(0.0).drawn_stops(), vec![false; 5]);
// With the stop at 0.8 opaque, the pairs next to it with width are drawn.
assert_eq!(out_of_order(1.0).drawn_stops(), vec![true, true, false, false, false]);
// The last stop colors the offsets above every stop's, even when it is out of order.
assert_eq!(g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)]).drawn_stops(), vec![false, false, true]);
// The first stop colors the offsets up to its own.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, false, false]);
// One stop, every stop clear, and no stops at all.
assert_eq!(g(vec![stop(0.5, 1.0)]).drawn_stops(), vec![true]);
assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![false, false]);
assert!(g(vec![]).drawn_stops().is_empty());
// A freeform gradient draws from its points: its stops count while any is above opacity 0.
let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]);
free.kind = GradientKind::Freeform;
assert_eq!(free.drawn_stops(), vec![true, true, true]);
free.stops.iter_mut().for_each(|s| s.opacity = 0.0);
assert_eq!(free.drawn_stops(), vec![false; 3]);
}
/// Taken as opaque, every stop that colors a stretch of positive width counts, and the first and
/// last stops, whatever their opacity; an out-of-order stop whose pairs have no width does not.
#[test]
fn reached_stops_leave_out_only_stops_without_width() {
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// Clear stops in order color their stretches once opaque.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true; 3]);
assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true, true]);
// The stop at 0.3 sits inside the stretch the stops at 0 and 0.8 cover.
let out_of_order = g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(out_of_order.reached_stops(), vec![true, true, false, true, true]);
// A stop at the offset of the stop before it: its pair has no width, the next one does.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 1.0), stop(1.0, 1.0)]).reached_stops(), vec![true, true, true]);
assert_eq!(
g(vec![stop(0.0, 1.0), stop(0.6, 1.0), stop(0.6, 1.0), stop(0.2, 1.0), stop(1.0, 1.0)]).reached_stops(),
vec![true, true, false, true, true]
);
assert!(g(vec![]).reached_stops().is_empty());
// Every pair the sampler picks between the ends has both its stops counted.
let stops: Vec<GradientStop> = [0.1, 0.4, 0.4, 0.2, 0.9, 0.5].iter().map(|&o| stop(o, 1.0)).collect();
let g = g(stops);
let (reached, reach) = (g.reached_stops(), StopReach::new(&g.stops));
for t in (5..=95).map(|k| k as f32 / 100.0).filter(|&t| t > 0.1 && t <= 0.9) {
let k = reach.0.partition_point(|r| *r < t);
assert!(reached[k] && reached[k + 1], "pair {k} at {t}: {reached:?}");
}
}
/// Repeated offsets turn off the direct midpoint mix, and the midpoints still come out as
/// `sample` gives them, with work that stays near linear in the stops (thousands of stops at one
/// offset used to scan them all for each midpoint).
+37 -4
View File
@@ -435,9 +435,10 @@ impl Paint {
}
/// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each
/// gradient stop) with their link and tint; `f` returns true when it changed them. A colour
/// left without a link gets tint 1. A freeform gradient's points, which it draws from, go
/// through `f` too, as unlinked colours (a link `f` gives them is dropped). Returns whether
/// anything changed.
/// left without a link gets tint 1, and a stop left without one also loses its process plates
/// ([`GradientStop::plates`], which print with a linked spot tint). A freeform gradient's
/// points, which it draws from, go through `f` too, as unlinked colours (a link `f` gives them
/// is dropped). Returns whether anything changed.
pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
let mut one = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let changed = f(c, link, tint);
@@ -449,7 +450,11 @@ impl Paint {
match self {
Paint::Solid { color, swatch, tint } => one(color, swatch, tint),
Paint::Gradient(g) => {
let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch);
let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| {
let changed = one(&mut s.color, &mut s.swatch, &mut s.tint);
let unplated = s.swatch.is_none() && s.plates.take().is_some();
changed | unplated | ch
});
let points =
g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()).fold(false, |ch, p| one(&mut p.color, &mut None, &mut 1.0) | ch);
stops | points
@@ -593,6 +598,34 @@ mod tests {
assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5));
}
#[test]
fn mapping_links_drops_the_process_plates_of_unlinked_stops() {
let mut gradient = Gradient::default();
let ink = Color::cmyk(0.0, 0.4, 0.0, 0.0);
gradient.stops[0].swatch = Some("Ink".into());
(gradient.stops[0].color, gradient.stops[0].tint, gradient.stops[0].plates) = (ink, 0.4, Some([0.3, 0.0, 0.0, 0.0]));
let mut g = Paint::Gradient(Box::new(GradientPaint::new(gradient)));
// Unlinking the stop takes its plates with its tint and keeps the colour it shows.
assert!(g.map_links(&mut |_, l, _| l.take().is_some()));
let Paint::Gradient(gp) = &g else { panic!("a gradient") };
let st = &gp.gradient.stops[0];
assert_eq!((st.color, st.swatch.as_deref(), st.tint, st.plates), (ink, None, 1.0, None));
// A linked stop keeps its plates.
let mut linked = Gradient::default();
(linked.stops[0].swatch, linked.stops[0].plates) = (Some("Ink".into()), Some([0.3, 0.0, 0.0, 0.0]));
let mut l = Paint::Gradient(Box::new(GradientPaint::new(linked)));
assert!(!l.map_links(&mut |_, _, _| false));
let Paint::Gradient(lp) = &l else { panic!("a gradient") };
assert_eq!(lp.gradient.stops[0].plates, Some([0.3, 0.0, 0.0, 0.0]));
// An unlinked stop that still has plates loses them, and that counts as a change.
let mut stale = Gradient::default();
stale.stops[1].plates = Some([0.0, 0.0, 0.2, 0.0]);
let mut p = Paint::Gradient(Box::new(GradientPaint::new(stale)));
assert!(p.map_links(&mut |_, _, _| false));
let Paint::Gradient(pp) = &p else { panic!("a gradient") };
assert!(pp.gradient.stops.iter().all(|s| s.plates.is_none()));
}
#[test]
fn lab_colours_serialize_with_their_tag_and_keep_their_model() {
let c = Color::lab(52.5, 70.0, -30.0);
+10
View File
@@ -271,6 +271,16 @@ impl Appearance {
_ => None,
})
}
/// Does any fill draw: one that is shown, has paint and has an opacity above 0, wherever it
/// sits in the stack?
pub fn has_drawn_fill(&self) -> bool {
self.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() && f.opacity > 0.0))
}
/// Does any stroke draw: one that is shown, has paint, a weight above 0 and an opacity above
/// 0, wherever it sits in the stack?
pub fn has_drawn_stroke(&self) -> bool {
self.painted_strokes().any(|s| s.width > 0.0 && s.opacity > 0.0)
}
/// Largest distance the painted area can extend beyond any geometry (see
/// [`StrokeLayer::reach`]); paths measure their own with [`Self::stroked_bounds`].
pub fn outset(&self) -> f64 {
+22
View File
@@ -79,6 +79,28 @@ fn basic_means_one_plain_fill_and_stroke() {
assert!(!e.is_basic(), "an effect");
}
#[test]
fn drawn_fills_and_strokes_count_every_row() {
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w));
let black = || Paint::solid(Color::BLACK);
assert!(!Appearance::default().has_drawn_fill() && !Appearance::default().has_drawn_stroke());
// A painted lower row draws under an unpainted top row.
let a = Appearance { items: vec![fill(black()), stroke(black(), 2.0), fill(Paint::None), stroke(Paint::None, 1.0)], ..Appearance::default() };
assert!(a.has_drawn_fill() && a.has_drawn_stroke());
// A hidden painted row, a zero opacity or a zero weight draws nothing.
let mut b = Appearance { items: vec![fill(black()), stroke(black(), 2.0)], ..Appearance::default() };
b.fill_mut().unwrap().visible = false;
b.stroke_mut().unwrap().visible = false;
assert!(!b.has_drawn_fill() && !b.has_drawn_stroke());
let mut c = Appearance::default_art();
c.fill_mut().unwrap().opacity = 0.0;
c.stroke_mut().unwrap().opacity = 0.0;
assert!(!c.has_drawn_fill() && !c.has_drawn_stroke());
let d = Appearance::basic(Paint::None, black(), 0.0);
assert!(!d.has_drawn_fill() && !d.has_drawn_stroke());
}
#[test]
fn set_creates_missing() {
let mut a = Appearance::default();
+142 -15
View File
@@ -45,7 +45,24 @@ fn lerp_f64(x: f64, y: f64, t: f64) -> f64 {
x + (y - x) * t
}
/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`).
/// The process plates between two stops linked to one spot swatch: each of C, M, Y and K in turn,
/// a stop without plates counting as none of each. The ends keep their own plates.
fn lerp_plates(a: Option<[f32; 4]>, b: Option<[f32; 4]>, t: f32) -> Option<[f32; 4]> {
if t <= 0.0 {
return a;
}
if t >= 1.0 {
return b;
}
if a.is_none() && b.is_none() {
return None;
}
let (a, b) = (a.unwrap_or_default(), b.unwrap_or_default());
Some(std::array::from_fn(|i| lerp_f32(a[i], b[i], t)))
}
/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`), with no
/// process plates (a solid has none).
fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> Gradient {
let mut g = like.clone();
for s in &mut g.stops {
@@ -53,16 +70,18 @@ fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) ->
s.opacity = 1.0;
s.swatch = swatch.map(str::to_string);
s.tint = tint;
s.plates = None;
}
g
}
/// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops
/// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks.
fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
/// The swatch, tint and process plates `g` paints at `t`: a stop's own link, or the shared swatch
/// of the two stops around `t` at the tint and plates between theirs (unlinked when they differ),
/// as [`Gradient::sample_with`] walks.
fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32, Option<[f32; 4]>) {
let stops = &g.stops;
let link = |s: &GradientStop| (s.swatch.clone(), s.tint);
let Some(first) = stops.first() else { return (None, 1.0) };
let link = |s: &GradientStop| (s.swatch.clone(), s.tint, s.plates);
let Some(first) = stops.first() else { return (None, 1.0, None) };
if t <= first.offset {
return link(first);
}
@@ -74,14 +93,14 @@ fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
let m = a.midpoint.clamp(0.01, 0.99);
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
return match (&a.swatch, &b.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v)),
_ if v <= 0.0 => link(a),
_ if v >= 1.0 => link(b),
_ => (None, 1.0),
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v), lerp_plates(a.plates, b.plates, v)),
_ => (None, 1.0, None),
};
}
}
stops.last().map_or((None, 1.0), link)
stops.last().map_or((None, 1.0, None), link)
}
/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred).
@@ -91,8 +110,8 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
.iter()
.map(|&o| {
let (color, opacity) = g.sample_keep_model(o);
let (swatch, tint) = link_at(g, o);
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
let (swatch, tint, plates) = link_at(g, o);
GradientStop { opacity, swatch, tint, plates, ..GradientStop::new(o, color) }
})
.collect();
out
@@ -139,16 +158,18 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
.iter()
.zip(&gb.stops)
.map(|(sa, sb)| {
// Stops on two tints of one swatch stay linked to it, as solids do (#3234).
let (swatch, tint) = match (&sa.swatch, &sb.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t)),
_ => (None, 1.0),
// Stops on two tints of one swatch stay linked to it, as solids do (#3234), and keep
// the process plates printed with it.
let (swatch, tint, plates) = match (&sa.swatch, &sb.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t), lerp_plates(sa.plates, sb.plates, t)),
_ => (None, 1.0, None),
};
GradientStop {
opacity: lerp_f32(sa.opacity, sb.opacity, t),
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
swatch,
tint,
plates,
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
}
})
@@ -2462,6 +2483,112 @@ mod tests {
assert!(m.gradient.stops.iter().any(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
}
/// A stop linked to the spot swatch "Spot" at `tint`, printed with process `plates`.
fn spot_stop(offset: f32, tint: f32, plates: Option<[f32; 4]>) -> GradientStop {
let mut stop = GradientStop::new(offset, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
stop.swatch = Some("Spot".into());
stop.tint = tint;
stop.plates = plates;
stop
}
fn gradient_paint(stops: Vec<GradientStop>) -> Paint {
Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })))
}
fn assert_plates(got: Option<[f32; 4]>, want: [f32; 4]) {
let got = got.unwrap_or_else(|| panic!("plates {want:?}, not none"));
assert!(got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}");
}
#[test]
fn blend_gradient_spot_process_plates_interpolate() {
let (p, q) = ([0.2, 0.4, 0.0, 0.0], [0.6, 0.0, 0.8, 1.0]);
let a = gradient_paint(vec![spot_stop(0.0, 0.2, Some(p)), spot_stop(1.0, 0.2, Some(p))]);
let b = gradient_paint(vec![spot_stop(0.0, 1.0, Some(q)), spot_stop(1.0, 1.0, Some(q))]);
let Paint::Gradient(m) = lerp_paint(&a, &b, 0.25) else { panic!() };
for s in &m.gradient.stops {
assert_eq!(s.swatch.as_deref(), Some("Spot"));
assert!((s.tint - 0.4).abs() < 1e-6, "{}", s.tint);
assert_plates(s.plates, [0.3, 0.3, 0.2, 0.25]);
}
// Without plates on either side the steps print the spot ink alone, as before.
let bare = |tint| gradient_paint(vec![spot_stop(0.0, tint, None), spot_stop(1.0, tint, None)]);
let Paint::Gradient(m) = lerp_paint(&bare(0.2), &bare(1.0), 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.plates.is_none()), "{:?}", m.gradient.stops);
}
#[test]
fn resampled_gradient_spot_process_plates_interpolate() {
let (p, q, r) = ([0.0, 0.4, 0.0, 0.0], [0.6, 0.4, 0.3, 0.0], [0.2, 0.2, 0.2, 0.2]);
let mut first = spot_stop(0.0, 0.2, Some(p));
first.midpoint = 0.25;
let two = Gradient { stops: vec![first, spot_stop(1.0, 0.8, Some(q))], ..Gradient::default() };
let three =
Gradient { stops: vec![spot_stop(0.0, 1.0, Some(r)), spot_stop(0.5, 1.0, Some(r)), spot_stop(1.0, 1.0, Some(r))], ..Gradient::default() };
let Some((ra, rb)) = common_stops(&two, &three) else { panic!() };
let offsets: Vec<f32> = ra.stops.iter().map(|s| s.offset).collect();
assert_eq!(offsets, [0.0, 0.25, 0.5, 1.0]);
// The original stops keep their plates; between them, the midpoint at 0.25 puts 0.5 two
// thirds of the way along the inks.
assert_eq!((ra.stops[0].plates, ra.stops[3].plates), (Some(p), Some(q)));
assert_plates(ra.stops[1].plates, [0.3, 0.4, 0.15, 0.0]);
assert_plates(ra.stops[2].plates, [0.4, 0.4, 0.2, 0.0]);
assert!((ra.stops[2].tint - 0.6).abs() < 1e-5, "{}", ra.stops[2].tint);
assert!(rb.stops.iter().all(|s| s.plates == Some(r)), "{:?}", rb.stops);
let Paint::Gradient(m) = lerp_paint(&gradient_paint(two.stops), &gradient_paint(three.stops), 0.5) else { panic!() };
assert_eq!(m.gradient.stops.len(), 4);
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops);
assert_plates(m.gradient.stops[2].plates, [0.3, 0.3, 0.2, 0.1]);
}
#[test]
fn blend_gradient_missing_plates_mean_zero() {
let p = [0.4, 0.8, 0.0, 0.2];
let with = gradient_paint(vec![spot_stop(0.0, 1.0, Some(p)), spot_stop(1.0, 1.0, Some(p))]);
let without = gradient_paint(vec![spot_stop(0.0, 1.0, None), spot_stop(1.0, 1.0, None)]);
let Paint::Gradient(m) = lerp_paint(&without, &with, 0.25) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops);
m.gradient.stops.iter().for_each(|s| assert_plates(s.plates, [0.1, 0.2, 0.0, 0.05]));
// The ends keep their own plates.
let Paint::Gradient(m) = lerp_paint(&without, &with, 0.0) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.plates.is_none()), "{:?}", m.gradient.stops);
// Another swatch, or an unlinked stop: process steps with no plates, as before.
let mut other = spot_stop(0.0, 1.0, Some(p));
other.swatch = Some("Other".into());
let other = gradient_paint(vec![other.clone(), GradientStop { offset: 1.0, ..other }]);
let mut plain = spot_stop(0.0, 1.0, None);
plain.swatch = None;
let plain = gradient_paint(vec![plain.clone(), GradientStop { offset: 1.0, ..plain }]);
for b in [&other, &plain] {
let Paint::Gradient(m) = lerp_paint(&with, b, 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none() && s.plates.is_none()), "{:?}", m.gradient.stops);
}
}
#[test]
fn blend_solid_to_spot_gradient_does_not_copy_plates() {
let p = [0.4, 0.8, 0.0, 0.2];
let gradient = gradient_paint(vec![spot_stop(0.0, 1.0, Some(p)), spot_stop(1.0, 1.0, Some(p))]);
let Paint::Gradient(g) = &gradient else { panic!() };
assert!(solid_gradient(Color::BLACK, Some("Spot"), 0.2, &g.gradient).stops.iter().all(|s| s.plates.is_none()));
let solid = Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(0.2), swatch: Some("Spot".into()), tint: 0.2 };
// The solid's end has no plates: they grow from none toward the gradient's, both ways round.
for (a, b, t) in [(&solid, &gradient, 0.25), (&gradient, &solid, 0.75)] {
let Paint::Gradient(m) = lerp_paint(a, b, t) else { panic!() };
for s in &m.gradient.stops {
assert_eq!(s.swatch.as_deref(), Some("Spot"));
assert!((s.tint - 0.4).abs() < 1e-6, "{}", s.tint);
assert_plates(s.plates, [0.1, 0.2, 0.0, 0.05]);
}
}
// A process solid unlinks the steps, and they take no plates.
let Paint::Gradient(m) = lerp_paint(&Paint::solid(Color::BLACK), &gradient, 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none() && s.plates.is_none()), "{:?}", m.gradient.stops);
}
#[test]
fn gradients_with_different_stop_counts_interpolate() {
let g = |stops: &[(f32, Color)]| {
+124 -16
View File
@@ -386,6 +386,21 @@ impl ImageBlob {
if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }
}
/// The CMYK ICC profile a CMYK image carries: a four-component JPEG's `ICC_PROFILE` APP2
/// segments put together, or a CMYK TIFF's tag 34675. `None` for other images, and for a
/// profile that isn't one of CMYK colors. Reads only the file's headers and tags.
pub fn cmyk_icc_profile(&self) -> Option<Vec<u8>> {
let bytes = self.bytes.as_slice();
if jpeg_components(bytes) == Some(4) {
return cmyk_icc(jpeg_icc(bytes));
}
let (kind, mut d) = tiff(bytes)?;
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) {
return None;
}
cmyk_icc(tiff_icc(&mut d))
}
/// The ink amounts around pixel (`x`, `y`) of a CMYK image, in the pixel space of an image
/// object of `object` (width, height) pixels: the average of the `size` × `size` pixels centered
/// on it, clipped to the image, as C, M, Y and K bytes. `None` outside the image and for images
@@ -515,9 +530,10 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi:
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be
/// stored together or one plane per channel. `None` for other images and ones larger than
/// [`MAX_CMYK_PIXELS`].
/// channels are left out). Associated (premultiplied) alpha is divided back out, and a pixel with
/// no associated alpha has no color. 16-bit samples are converted at 16 bits, and only the result
/// is rounded to 8 bits. The samples may be stored together or one plane per channel. `None` for
/// other images and ones larger than [`MAX_CMYK_PIXELS`].
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
@@ -535,20 +551,33 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
_ => return None,
};
let n = usize::try_from(pixels(w, h)?).ok()?;
let data = read_samples(&mut d, n, samples)?;
let mut out = Vec::with_capacity(n * 4);
for p in data.chunks_exact(samples) {
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
let a = f32::from(opacity) / 255.0;
let level = |v: u8| {
let v = f32::from(v) / 255.0;
if extra == Some(1) && a > 0.0 { (v / a).min(1.0) } else { v }
let data = read_samples_at_depth(&mut d, n, samples)?;
// Sample `i` of the image, 0..1.
let level_at = |i: usize| match &data {
Samples::Eight(v) => v.get(i).map(|&s| f32::from(s) / 255.0),
Samples::Sixteen(v) => v.get(i).map(|&s| f32::from(s) / 65535.0),
};
// Sample `i` of the image as 8 bits, a 16-bit sample rounded to the nearest.
let byte_at = |i: usize| match &data {
Samples::Eight(v) => v.get(i).copied(),
Samples::Sixteen(v) => v.get(i).map(|&s| u8::try_from((u32::from(s) + 128) / 257).unwrap_or(u8::MAX)),
};
let mut out = Vec::with_capacity(n.checked_mul(4)?);
for first in (0..n).map(|p| p * samples) {
let (opacity, a) = if alpha { (byte_at(first + colour).unwrap_or(u8::MAX), level_at(first + colour).unwrap_or(1.0)) } else { (u8::MAX, 1.0) };
let level = |s: usize| {
let v = level_at(first + s).unwrap_or(0.0);
match extra {
Some(1) if a > 0.0 => (v / a).min(1.0),
// No associated alpha: the color is zero (TIFF 6.0, Associated Alpha Handling).
Some(1) if alpha => 0.0,
_ => v,
}
};
let [r, g, b] = match *p {
[c, m, y, k, ..] if colour == 4 => rgb([level(c), level(m), level(y), level(k)]),
[v, ..] if photometric == Some(0) => [1.0 - level(v); 3],
[v, ..] => [level(v); 3],
[] => continue,
let [r, g, b] = match colour {
4 => rgb([level(0), level(1), level(2), level(3)]),
_ if photometric == Some(0) => [1.0 - level(0); 3],
_ => [level(0); 3],
};
out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
out.push(opacity);
@@ -767,6 +796,85 @@ mod tests {
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
}
/// Five 16-bit samples a pixel: the four inks and one extra sample, which the file's
/// `ExtraSamples` names.
struct Cmyka16;
impl tiff::encoder::colortype::ColorType for Cmyka16 {
type Inner = u16;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::CMYK;
const BITS_PER_SAMPLE: &'static [u16] = &[16; 5];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 5];
fn horizontal_predict(row: &[u16], result: &mut Vec<u16>) {
result.extend_from_slice(row);
}
}
/// Two 16-bit samples a pixel: gray and one extra sample.
struct GrayExtra16;
impl tiff::encoder::colortype::ColorType for GrayExtra16 {
type Inner = u16;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::BlackIsZero;
const BITS_PER_SAMPLE: &'static [u16] = &[16, 16];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 2];
fn horizontal_predict(row: &[u16], result: &mut Vec<u16>) {
result.extend_from_slice(row);
}
}
/// A one-row TIFF of `C`'s pixels `data`, with the ExtraSamples value `extra`.
fn extra_tiff<C: tiff::encoder::colortype::ColorType<Inner = u16>>(extra: u16, data: &[u16]) -> Vec<u8> {
let samples = C::BITS_PER_SAMPLE.len();
let mut file = Cursor::new(Vec::new());
let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<C>(u32::try_from(data.len() / samples).unwrap(), 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[extra][..]).unwrap();
img.write_data(data).unwrap();
file.into_inner()
}
/// 16-bit CMYK with alpha converts its 16-bit inks and alpha, and rounds only the result to 8
/// bits; the high byte of each sample alone would give other pixels.
#[test]
fn a_16_bit_cmyk_tiff_with_alpha_converts_at_16_bits() {
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
let read = |extra: u16, data: &[u16]| extra_samples_tiff_rgba(&extra_tiff::<Cmyka16>(extra, data), naive).expect("CMYKA reads").into_raw();
// Unassociated: 129 of cyan, opaque; no ink at an alpha of 129; no ink at an alpha of 386.
let straight = [129, 0, 0, 0, 65535, 0, 0, 0, 0, 129, 0, 0, 0, 0, 386];
assert_eq!(read(2, &straight), [254, 255, 255, 255, 255, 255, 255, 1, 255, 255, 255, 2]);
// Associated: cyan 128 at an alpha of 256 (half cyan); K 255 at an alpha of 511 (about half K).
let premultiplied = [128, 0, 0, 0, 256, 0, 0, 0, 255, 511];
assert_eq!(read(1, &premultiplied), [128, 255, 255, 1, 128, 128, 128, 2], "divided by the 16-bit alpha");
}
/// 16-bit gray with alpha converts at 16 bits as well.
#[test]
fn a_16_bit_grayscale_tiff_with_alpha_converts_at_16_bits() {
let read = |extra: u16| {
// A gray of 129, opaque, then 128 at an alpha of 256.
let bytes = extra_tiff::<GrayExtra16>(extra, &[129, 65535, 128, 256]);
extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("gray with alpha reads").into_raw()
};
assert_eq!(read(2), [1, 1, 1, 255, 0, 0, 0, 1], "unassociated");
assert_eq!(read(1), [1, 1, 1, 255, 128, 128, 128, 1], "associated, divided by the 16-bit alpha");
}
/// With associated alpha, a pixel whose alpha is zero has no color (TIFF 6.0, Associated
/// Alpha Handling), whatever its color samples hold.
#[test]
fn a_pixel_with_no_associated_alpha_has_no_color() {
use tiff::encoder::{TiffEncoder, colortype::CMYKA8};
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
let mut file = Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<CMYKA8>(1, 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
img.write_data(&[255, 0, 0, 0, 0]).unwrap();
let cmyk = extra_samples_tiff_rgba(&file.into_inner(), naive).expect("CMYKA reads");
assert_eq!(cmyk.into_raw(), [255, 255, 255, 0], "no ink");
let gray = extra_samples_tiff_rgba(&extra_tiff::<GrayExtra16>(1, &[50_000, 0]), |_| [0.0; 3]).expect("gray reads");
assert_eq!(gray.into_raw(), [0, 0, 0, 0], "a gray of zero");
}
/// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel,
/// times 2 planes.
#[test]
+4
View File
@@ -815,6 +815,10 @@ impl Dimension {
if self.line_between_points() && !old.line_between_points() {
self.keep_gap(&old);
}
} else if self.mode == DimensionMode::Linear && xf.determinant() < 0.0 && (self.b - self.a).hypot() > 1e-9 {
// A reflection turns the left normal of a → b to the other side, so an aligned
// dimension's line stays on the reflected side of its points.
self.offset = -self.offset;
}
if self.mode == DimensionMode::Linear {
self.length = self.measured();
+2
View File
@@ -79,6 +79,7 @@ impl Document {
index: _,
guide_cache: _,
inline_cycles: _,
mounted_art: _,
} = self;
*version == other.version
&& *title == other.title
@@ -264,6 +265,7 @@ impl Document {
index: Default::default(),
guide_cache: Default::default(),
inline_cycles: Default::default(),
mounted_art: vec![],
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
+4 -2
View File
@@ -128,8 +128,8 @@ impl Document {
}
/// The roots of every tree of the document's art: the layers, symbol definitions, graph designs,
/// pattern swatches and the original of the pattern being edited. Opacity masks hang off nodes
/// in these trees.
/// pattern swatches, the original of the pattern being edited and [`Self::mounted_art`].
/// Opacity masks hang off nodes in these trees.
pub fn art_roots(&self) -> impl Iterator<Item = &Arc<Node>> {
self.layers
.iter()
@@ -137,6 +137,7 @@ impl Document {
.chain(self.graph_designs.iter().map(|g| &g.art))
.chain(self.patterns.iter().flat_map(|p| p.art.iter()))
.chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter()))
.chain(self.mounted_art.iter().map(|(n, _)| n))
}
/// [`Self::art_roots`], to change.
@@ -147,6 +148,7 @@ impl Document {
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()))
.chain(self.mounted_art.iter_mut().map(|(n, _)| n))
}
/// Does `wants` hold for a node anywhere in the document's art ([`Self::art_roots`]) or its
+13 -63
View File
@@ -1,6 +1,7 @@
use super::document_ext::free_id;
use super::guides::Guide;
use super::limits::MAX_NEXT_ID;
use super::node::{held_guides, map_held, strip_held};
use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds};
use std::collections::{BTreeMap, HashSet};
@@ -272,8 +273,8 @@ impl Document {
return;
}
for l in &mut self.layers {
if let Some(held) = picked_in_layers(l, &Node::is_ruler_guide) {
drop_picked(l, held);
if let Some(held) = held_guides(l, &Node::is_ruler_guide) {
strip_held(Arc::make_mut(l), held);
}
}
self.saved_guides.extend(guides);
@@ -347,14 +348,14 @@ impl Document {
/// saved guide whose layer is gone opens. Returns the removed node, without the guides.
pub fn remove_edit_layer(&mut self, id: NodeId) -> Result<Arc<Node>, crate::DocError> {
let mut layer = self.remove(id)?;
let Some(held) = picked_in_layers(&layer, &Node::is_ruler_guide) else { return Ok(layer) };
let Some(held) = held_guides(&layer, &Node::is_ruler_guide) else { return Ok(layer) };
let mut guides = vec![];
layer.walk(&mut |c| {
if c.is_ruler_guide() {
guides.push(Arc::new(c.clone()));
}
});
drop_picked(&mut layer, held);
strip_held(Arc::make_mut(&mut layer), held);
let home = self.default_layer().unwrap_or_else(|| self.add_layer(None));
self.children_mut(Some(home))?.extend(guides);
Ok(layer)
@@ -418,8 +419,8 @@ impl Document {
}
let pick = |n: &Node| n.is_ruler_guide() && gone.contains(&n.id);
for l in &mut self.layers {
if let Some(held) = picked_in_layers(l, &pick) {
drop_picked(l, held);
if let Some(held) = held_guides(l, &pick) {
strip_held(Arc::make_mut(l), held);
}
}
sel.guides.retain(|id| !gone.contains(id));
@@ -434,8 +435,8 @@ impl Document {
fn edit_artboard_guides(&mut self, id: u32, f: &mut impl FnMut(bool, &mut f64)) {
let pick = |n: &Node| matches!(n.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id);
for l in &mut self.layers {
if let Some(held) = picked_in_layers(l, &pick) {
edit_picked(l, held, f);
if let Some(held) = held_guides(l, &pick) {
map_held(Arc::make_mut(l), held, f);
}
}
}
@@ -569,57 +570,6 @@ impl Picked {
}
}
/// The children of `n`, if it is a layer, that `pick` picks (with nothing inside), and its
/// sublayers holding one (with what they hold), or `None` when it holds none. `pick` looks at
/// each child of the layers once.
fn picked_in_layers(n: &Node, pick: &impl Fn(&Node) -> bool) -> Option<Picked> {
if !n.is_layer() {
return None;
}
let children = n
.children()
.into_iter()
.flatten()
.enumerate()
.filter_map(|(i, c)| if pick(c) { Some((i, Picked::default())) } else { picked_in_layers(c, pick).map(|p| (i, p)) })
.collect();
Picked { children, mask: None }.some()
}
/// Remove the children of `layer` and of its sublayers that [`picked_in_layers`] picked (`held`).
/// Only the layers holding one are copied, and only those are visited.
fn drop_picked(layer: &mut Arc<Node>, held: Picked) {
let Some(ch) = Arc::make_mut(layer).children_mut() else { return };
let mut held = held.children.into_iter().peekable();
let mut next = 0;
ch.retain_mut(|c| {
let at = next;
next += 1;
let Some((_, inner)) = held.next_if(|(i, _)| *i == at) else { return true };
// A sublayer holds something; a child picked itself has nothing inside.
if inner.is_empty() {
return false;
}
drop_picked(c, inner);
true
});
}
/// Change with `f` (whether it is vertical, its position) the line of each ruler guide of `layer`
/// and of its sublayers that [`picked_in_layers`] picked (`held`), in the order they lie in. Only
/// the layers holding one, and the guides, are copied, and only those layers are visited.
fn edit_picked(layer: &mut Arc<Node>, held: Picked, f: &mut impl FnMut(bool, &mut f64)) {
let Some(ch) = Arc::make_mut(layer).children_mut() else { return };
for (i, inner) in held.children {
let Some(c) = ch.get_mut(i) else { continue };
if !inner.is_empty() {
edit_picked(c, inner, f);
} else if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind {
f(*vertical, pos);
}
}
}
/// `n` picked itself when it is a ruler guide by `is_guide`, else the ruler guides below it
/// ([`guides_below`]).
fn guide_or_below(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option<Picked> {
@@ -872,9 +822,9 @@ mod tests {
looked.set(looked.get() + 1);
on_artboard_7(n)
};
let held = picked_in_layers(&d.layers[0], &pick).unwrap();
let held = held_guides(&d.layers[0], &pick).unwrap();
assert_eq!(looked.get(), NESTED_CHILDREN, "each child of the layers is looked at once");
drop_picked(&mut d.layers[0], held);
strip_held(Arc::make_mut(&mut d.layers[0]), held);
assert_eq!(looked.get(), NESTED_CHILDREN, "removing looks at nothing again");
assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a sublayer with nothing to remove is not copied");
@@ -932,10 +882,10 @@ mod tests {
looked.set(looked.get() + 1);
on_artboard_7(n)
};
let held = picked_in_layers(&d.layers[0], &pick).unwrap();
let held = held_guides(&d.layers[0], &pick).unwrap();
assert_eq!(looked.get(), NESTED_CHILDREN, "each child of the layers is looked at once");
let mut seen = vec![];
edit_picked(&mut d.layers[0], held, &mut |vertical, pos: &mut f64| {
map_held(Arc::make_mut(&mut d.layers[0]), held, &mut |vertical, pos: &mut f64| {
seen.push((vertical, *pos));
*pos += 9.0;
});
+100 -13
View File
@@ -94,25 +94,45 @@ pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent)
Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false }
}
/// Visits one colour: the colour, its swatch link and its tint, all of which it may change.
pub type ColorVisitor<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) + 'a;
/// Process plates a colour prints together with its spot tint (C, M, Y, K, 0..1): a gradient
/// stop's [`vectorcraft_color::GradientStop::plates`]; `None` for every other colour.
pub type StopPlates = Option<[f32; 4]>;
/// [`inks`] of a colour that may print process plates `plates` with its spot tint. The plates
/// print only beside a spot ink: a colour without a valid spot link, or linked to Registration,
/// prints as [`inks`] separates it. A plate amount that is not a finite number prints no ink.
pub fn stop_inks(doc: &Document, c: &Cms, color: &Color, link: Link, plates: StopPlates, intent: Intent) -> Inks {
let mut ink = inks(doc, c, color, link, intent);
if let (Some(p), Some(_)) = (plates, &ink.spot) {
ink.cmyk = p.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
}
ink
}
/// Visits one colour: the colour, its swatch link, its tint and its process plates
/// ([`StopPlates`]), all of which it may change.
pub type ColorVisitor<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32, &mut StopPlates) + 'a;
fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) {
match p {
Paint::Solid { color, swatch, tint } => f(color, swatch, tint),
Paint::Solid { color, swatch, tint } => f(color, swatch, tint, &mut None),
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
f(&mut s.color, &mut s.swatch, &mut s.tint);
f(&mut s.color, &mut s.swatch, &mut s.tint, &mut s.plates);
}
for p in g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()) {
f(&mut p.color, &mut None, &mut 1.0, &mut None);
}
}
_ => {}
}
}
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an
/// image's colorize color, the fills and strokes a dynamic symbol instance keeps for objects of its
/// art, not opacity masks) with its swatch link and tint. Mesh points have no link: changes to it
/// are dropped.
/// Visit every colour of a node tree (fills, strokes, gradient stops, a freeform gradient's
/// points, text runs, mesh points, an image's colorize color, the fills and strokes a dynamic
/// symbol instance keeps for objects of its art, not opacity masks) with its swatch link, tint and
/// process plates. Freeform and mesh points have no link or plates: a link or plates `f` gives
/// them are dropped.
pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
for it in &mut n.appearance.items {
match it {
@@ -137,7 +157,7 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
}
NodeKind::Mesh(m) => {
for p in &mut m.points {
f(&mut p.color, &mut None, &mut 1.0);
f(&mut p.color, &mut None, &mut 1.0, &mut None);
}
}
_ => {}
@@ -149,11 +169,16 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
}
}
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh
/// points, a dynamic symbol instance's own fills and strokes) given its swatch link and tint,
/// keeping the links.
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, a freeform
/// gradient's points, text runs, mesh points, a dynamic symbol instance's own fills and strokes)
/// given its swatch link and tint, keeping the links and process plates.
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) {
visit_node_colors(n, &mut |c, swatch, tint| *c = f(c, swatch.as_deref().map(|s| (s, *tint))));
map_node_inks(n, &mut |c, link, _| f(c, link));
}
/// [`map_node_colors`], with each colour's process plates ([`StopPlates`]) passed to `f` too.
pub fn map_node_inks(n: &mut Node, f: &mut dyn FnMut(&Color, Link, StopPlates) -> Color) {
visit_node_colors(n, &mut |c, swatch, tint, plates| *c = f(c, swatch.as_deref().map(|s| (s, *tint)), *plates));
}
/// Apply `f` to every colour in the document's art and symbol definitions.
@@ -170,8 +195,70 @@ pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) -
mod tests {
use super::*;
use crate::{NodeId, SymbolOverride};
use vectorcraft_color::{Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, GradientStop, Swatch};
use vectorcraft_geom::Affine;
/// A document with spot swatch `Gold` and process swatch `Teal`.
fn gold_doc() -> Document {
let mut d = Document::new(100.0, 100.0);
d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true });
d.swatches.push(Swatch { name: "Teal".into(), paint: Paint::solid(Color::cmyk(0.8, 0.0, 0.3, 0.0)), global: true, spot: false });
d
}
#[test]
fn a_spot_stops_process_plates_print_with_its_tint() {
let d = gold_doc();
let c = vectorcraft_color::cms::active();
let look = Color::cmyk(0.25, 0.1, 0.8, 0.05);
let ink = |link: Link, plates: StopPlates| stop_inks(&d, &c, &look, link, plates, Intent::RelativeColorimetric);
let mixed = ink(Some(("Gold", 0.5)), Some([0.25, 0.0, 0.4, 0.0]));
assert_eq!(mixed, Inks { cmyk: [0.25, 0.0, 0.4, 0.0], spot: Some(("Gold".into(), 0.5)), registration: false });
assert_eq!((mixed.on_plate("Cyan"), mixed.on_plate("Gold")), (0.25, 0.5));
// Amounts outside 0..1 are clamped; one that is not a number prints no ink.
let odd = ink(Some(("Gold", 1.0)), Some([f32::NAN, 2.0, -1.0, f32::INFINITY]));
assert_eq!(odd.cmyk, [0.0, 1.0, 0.0, 0.0]);
// Without plates the spot ink prints alone, as `inks` separates it.
assert_eq!(ink(Some(("Gold", 0.5)), None), inks(&d, &c, &look, Some(("Gold", 0.5)), Intent::RelativeColorimetric));
// Plates beside no spot ink print nothing of their own: a process swatch, an unknown
// swatch, no link and Registration separate as `inks` does.
for link in [Some(("Teal", 1.0)), Some(("Missing", 1.0)), None, Some((REGISTRATION, 0.5))] {
assert_eq!(ink(link, Some([0.25, 0.0, 0.4, 0.0])), inks(&d, &c, &look, link, Intent::RelativeColorimetric), "{link:?}");
}
}
#[test]
fn gradient_stops_pass_their_plates_and_freeform_points_are_visited() {
let mut stop = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05));
(stop.swatch, stop.tint, stop.plates) = (Some("Gold".into()), 0.5, Some([0.25, 0.0, 0.4, 0.0]));
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, Color::rgb(1.0, 1.0, 1.0))]));
let point = FreeformPoint::new(kurbo::Point::new(10.0, 10.0), Color::rgb(0.0, 1.0, 0.0));
g.freeform = Some(Freeform { points: vec![point], ..Default::default() });
let mut n = Node::path(
NodeId(1),
vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)),
crate::Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0),
);
let mut seen = vec![];
let gray = Color::Gray { k: 0.5 };
map_node_inks(&mut n, &mut |c, link, plates| {
seen.push((*c, link.map(|(s, t)| (s.to_string(), t)), plates));
gray
});
assert_eq!(
seen,
vec![
(Color::cmyk(0.25, 0.1, 0.8, 0.05), Some(("Gold".to_string(), 0.5)), Some([0.25, 0.0, 0.4, 0.0])),
(Color::rgb(1.0, 1.0, 1.0), None, None),
(Color::rgb(0.0, 1.0, 0.0), None, None),
]
);
let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| f.paint.clone()) else { panic!("no gradient fill") };
assert_eq!(g.gradient.stops[0].plates, Some([0.25, 0.0, 0.4, 0.0]), "mapping a colour keeps its plates");
assert!(g.gradient.stops.iter().all(|s| s.color == gray));
assert_eq!(g.freeform.map(|f| f.points[0].color), Some(gray), "the freeform point is mapped too");
}
#[test]
fn a_dynamic_instances_own_fills_and_strokes_are_visited() {
let red = Color::rgb(1.0, 0.0, 0.0);
+177 -36
View File
@@ -8,13 +8,20 @@ use std::sync::{Arc, RwLock, Weak};
use vectorcraft_geom::{Affine, Rect};
use crate::{Document, Node, NodeId, NodeKind, Symbol, TextObject};
use crate::symbols::SymbolNames;
use crate::{Document, Node, NodeId, NodeKind, Symbol, SymbolBoxes, TextObject};
/// The document key under which the app records each symbol's natural size (`{name: [w, h]}`).
pub const SYMBOL_SIZES: &str = "symbolSizes";
/// Half the side of the square symbol art is normalised to.
pub const SYMBOL_HALF: f64 = 10.0;
/// The box symbol art `art` is measured by in [`Document::symbol_art_bounds`]: its visual bounds,
/// or its geometric bounds when it has no visual bounds.
fn art_extent(art: &Node, boxes: &SymbolBoxes) -> Option<Rect> {
art.visual_bounds_with(boxes).or_else(|| art.geometric_bounds_with(boxes))
}
/// Usable art bounds: finite, non-degenerate.
fn usable(r: Option<Rect>) -> Option<Rect> {
r.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() >= 0.0 && r.height() > 1e-9)
@@ -35,27 +42,38 @@ impl Document {
}
/// Visual bounds of the art of the symbol named `name` at its natural size (see
/// [`Self::symbol_natural_xf`]), if it exists and has a usable extent.
/// [`Self::symbol_natural_xf`]), if it exists and has a usable extent. The symbol instances in
/// the art are measured by their own symbols' art ([`Node::visual_bounds_with`]), or by the
/// 20 × 20 square where [`crate::SymbolBoxes`] measures them by it.
pub fn symbol_art_bounds(&self, name: &str) -> Option<Rect> {
let s = self.symbols.iter().find(|s| s.name == name)?;
let b = usable(s.art.visual_bounds().or_else(|| s.art.geometric_bounds()))?;
self.symbol_art_bounds_in(name, &self.symbol_boxes())
}
/// [`Self::symbol_art_bounds`], with the symbols found and the instances measured by `boxes`
/// (new boxes of this document).
fn symbol_art_bounds_in(&self, name: &str, boxes: &SymbolBoxes) -> Option<Rect> {
let b = usable(art_extent(boxes.symbol_art(name)?, boxes))?;
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.
/// through the inline graphics and symbol instances in other symbols' art, so that its size
/// would depend on itself.
pub fn inline_art_bounds(&self, name: &str) -> Option<Rect> {
if self.inline_cycles().contains(name) { None } else { self.symbol_art_bounds(name) }
let (cyclic, names) = self.inline_cycles();
if cyclic.contains(name) { None } else { self.symbol_art_bounds_in(name, &self.symbol_boxes().with_names(&names)) }
}
/// 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<HashSet<String>> {
/// The names of the symbols that reach themselves through the inline graphics in symbol art
/// ([`InlineGraph`]), with each symbol name → the index of the first symbol of that name, found
/// once while the symbols stay as they are.
fn inline_cycles(&self) -> (Arc<HashSet<String>>, Arc<SymbolNames>) {
if let Some(found) = self.inline_cycles.get(&self.symbols) {
return found;
}
let found = Arc::new(InlineGraph::of(self).cyclic);
let graph = InlineGraph::of(self);
let found = (Arc::new(graph.cyclic), graph.names);
self.inline_cycles.set(&self.symbols, &found);
found
}
@@ -94,7 +112,8 @@ impl Document {
/// 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
/// inline graphics and symbol instances show, so a symbol's art is measured with its type, and
/// the type in the art of the symbols its instances show, 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<NodeId> {
@@ -241,34 +260,48 @@ fn inline_symbols(n: &Node) -> Vec<String> {
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.
/// The symbols of a document as their sizes depend on each other: a symbol's art depends on the
/// symbols its type shows inline, and on the symbols its instances show when those instances are
/// measured by their symbols' art ([`crate::SymbolBoxes`]).
struct InlineGraph {
/// Every symbol index, each after the symbols its art shows inline (except along a cycle).
/// Every symbol index, each after the symbols its art depends on (except along a cycle).
order: Vec<usize>,
/// The names of the symbols that reach themselves through inline graphics.
/// The names of the symbols that reach themselves through inline graphics. Instances alone make
/// no cycle: an instance of a symbol that holds an instance of itself is measured by the
/// 20 × 20 square, so every cycle passes through an inline graphic.
cyclic: HashSet<String>,
/// Each symbol name → the index of the first symbol of that name, which stay the same while
/// [`Document::resolve_inline_art_with`] changes the symbols' art.
names: Arc<SymbolNames>,
}
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).
/// The graph of `d`'s symbols: each symbol's art is walked once, and, when the art of any
/// symbol holds an inline graphic, measured once with the art boxes of the symbols its
/// instances show measured once in all ([`SymbolBoxes::consulted`]: an instance that does not
/// size the art, such as one a clipping path hides, adds nothing). The order and cycles are
/// found in time linear in the symbols and their inline references and instances (Tarjan's
/// strongly connected components, without recursion).
fn of(d: &Document) -> Self {
let index = symbol_index(d);
let names = Arc::new(d.symbol_names());
let boxes = d.symbol_boxes().quick().with_names(&names);
let inline: Vec<Vec<String>> = d.symbols.iter().map(|s| inline_symbols(&s.art)).collect();
// Without inline graphics in symbol art, no symbol's type depends on another symbol.
let any_inline = inline.iter().any(|v| !v.is_empty());
let edges: Vec<Vec<usize>> = d
.symbols
.iter()
.map(|s| {
let mut e: Vec<usize> = inline_symbols(&s.art).iter().filter_map(|n| index.get(n.as_str()).copied()).collect();
.zip(inline)
.map(|(s, inline)| {
let measured = if any_inline {
boxes.consulted(|| {
art_extent(&s.art, &boxes);
})
} else {
HashSet::new()
};
let shows = inline.into_iter().chain(measured);
let mut e: Vec<usize> = shows.filter_map(|n| names.get(n.as_str()).copied()).collect();
e.sort_unstable();
e.dedup();
e
@@ -276,12 +309,13 @@ impl InlineGraph {
.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 }
Self { order, cyclic, names }
}
/// [`Document::inline_art_bounds`] with the cycles found in this graph.
/// [`Document::inline_art_bounds`] with the cycles found in this graph, for `d` with the
/// symbols' names and order the graph was made with.
fn bounds(&self, d: &Document, name: &str) -> Option<Rect> {
if self.cyclic.contains(name) { None } else { d.symbol_art_bounds(name) }
if self.cyclic.contains(name) { None } else { d.symbol_art_bounds_in(name, &d.symbol_boxes().with_names(&self.names)) }
}
}
@@ -354,12 +388,16 @@ fn components(edges: &[Vec<usize>]) -> (Vec<usize>, Vec<bool>) {
#[derive(Default)]
pub(crate) struct InlineCycles(RwLock<Option<Arc<Found>>>);
/// 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<Node>)>, Arc<HashSet<String>>);
/// The names of the symbols on a cycle, and each symbol name → the index of the first symbol of that
/// name.
type Cycles = (Arc<HashSet<String>>, Arc<SymbolNames>);
/// The name and art of each symbol the cycles were found in, and what was found.
type Found = (Vec<(String, Weak<Node>)>, Cycles);
impl InlineCycles {
/// The cached names, when they were found in `symbols`.
fn get(&self, symbols: &[Symbol]) -> Option<Arc<HashSet<String>>> {
fn get(&self, symbols: &[Symbol]) -> Option<Cycles> {
let g = self.0.read().ok()?;
let (from, found) = &**g.as_ref()?;
let same = from.len() == symbols.len()
@@ -368,7 +406,7 @@ impl InlineCycles {
}
/// Keep `found`, found in `symbols`.
fn set(&self, symbols: &[Symbol], found: &Arc<HashSet<String>>) {
fn set(&self, symbols: &[Symbol], found: &Cycles) {
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())));
}
@@ -485,6 +523,109 @@ mod tests {
assert_eq!(count.get(), 2);
}
/// A rectangle `r` filled black, with a 2 pt black stroke.
fn stroked(d: &mut Document, r: Rect) -> Arc<Node> {
use vectorcraft_color::{Color, Paint};
let look = crate::Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 2.0);
Arc::new(Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), look))
}
fn instance(d: &mut Document, symbol: &str, xf: Affine) -> Arc<Node> {
Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf }))
}
/// Symbol `name` holding `art` in a group.
fn symbol(d: &mut Document, name: &str, art: Vec<Arc<Node>>) {
let group = Node::group(d.alloc_id(), art);
d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(group) });
}
#[test]
fn a_symbol_nested_in_symbol_art_is_measured_by_its_own_art() {
// `Far` is a rectangle from (200, 100) to (240, 130) with a 2 pt stroke, far from the
// 20 × 20 square around the origin.
let mut d = Document::new(500.0, 500.0);
let far = stroked(&mut d, Rect::new(200.0, 100.0, 240.0, 130.0));
symbol(&mut d, "Far", vec![far]);
let moved = instance(&mut d, "Far", Affine::translate((-100.0, 0.0)));
symbol(&mut d, "Moved", vec![moved.clone()]);
let scaled = instance(&mut d, "Far", Affine::scale(2.0));
symbol(&mut d, "Scaled", vec![scaled]);
symbol(&mut d, "Sized", vec![moved]);
d.unknown.insert(SYMBOL_SIZES.into(), serde_json::json!({ "Sized": [40.0, 20.0] }));
assert_eq!(d.symbol_art_bounds("Far"), Some(Rect::new(199.0, 99.0, 241.0, 131.0)));
// The stroke paints 1 pt around the moved rectangle.
assert_eq!(d.symbol_art_bounds("Moved"), Some(Rect::new(99.0, 99.0, 141.0, 131.0)));
// Scaled 2 times, the stroke keeps its weight.
assert_eq!(d.symbol_art_bounds("Scaled"), Some(Rect::new(399.0, 199.0, 481.0, 261.0)));
// At its natural size, 40 × 20 for the 20 × 20 square: 2 times across.
assert_eq!(d.symbol_art_bounds("Sized"), Some(Rect::new(198.0, 99.0, 282.0, 131.0)));
assert_eq!(d.inline_art_bounds("Moved"), d.symbol_art_bounds("Moved"));
}
#[test]
fn nested_instances_without_art_of_their_own_are_measured_by_the_square() {
let mut d = Document::new(500.0, 500.0);
let r = Rect::new(0.0, 0.0, 10.0, 10.0);
// An instance of a missing symbol and one of a symbol with no art, beside a rectangle.
symbol(&mut d, "Empty", vec![]);
let pieces = vec![stroked(&mut d, r), instance(&mut d, "Nowhere", Affine::translate((100.0, 0.0)))];
symbol(&mut d, "Missing", pieces);
let pieces = vec![stroked(&mut d, r), instance(&mut d, "Empty", Affine::translate((100.0, 0.0)))];
symbol(&mut d, "Holds empty", pieces);
let with_square = Some(Rect::new(-1.0, -10.0, 110.0, 11.0));
assert_eq!(d.symbol_art_bounds("Empty"), None);
assert_eq!(d.symbol_art_bounds("Missing"), with_square);
assert_eq!(d.symbol_art_bounds("Holds empty"), with_square);
// A symbol that holds an instance of itself: the instance is measured by the square, and
// an inline graphic of it is not an empty slot.
let pieces = vec![stroked(&mut d, r), instance(&mut d, "Loop", Affine::translate((100.0, 0.0)))];
symbol(&mut d, "Loop", pieces);
assert_eq!(d.symbol_art_bounds("Loop"), with_square);
assert_eq!(d.inline_art_bounds("Loop"), with_square);
// A chain of 40 symbols, each holding a rectangle and an instance of the next one 1 pt to
// the right: symbols nest 39 deep in Link 0's art, past the limit of 32, so its instance of
// Link 1 is measured by the square.
for i in 0..40 {
let unit = stroked(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0));
let next = instance(&mut d, &format!("Link {}", i + 1), Affine::translate((1.0, 0.0)));
symbol(&mut d, &format!("Link {i}"), if i < 39 { vec![unit, next] } else { vec![unit] });
}
assert_eq!(d.symbol_art_bounds("Link 0"), Some(Rect::new(-9.0, -10.0, 11.0, 10.0)));
// Link 8's art nests 31 deep: measured by the art of the links after it.
assert_eq!(d.symbol_art_bounds("Link 8"), Some(Rect::new(-1.0, -1.0, 33.0, 2.0)));
}
/// Type showing the symbol named `symbol` inline.
fn showing(d: &mut Document, symbol: &str) -> Arc<Node> {
let style = crate::CharStyle::default();
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Type", style.clone());
t.runs.push(crate::TextRun::new("", style));
t.runs[1].inline = Some(crate::InlineArt { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None });
Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))))
}
#[test]
fn only_instances_that_size_a_symbols_art_make_it_depend_on_their_symbol() {
// B's type shows A inline. A's art is a clip group: a clipping rectangle, then an instance
// of B that the rectangle clips, so A's size does not depend on B's.
let mut d = Document::new(500.0, 500.0);
let text = showing(&mut d, "A");
symbol(&mut d, "B", vec![text]);
let clip = stroked(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0));
let inner = instance(&mut d, "B", Affine::IDENTITY);
let group = Arc::new(Node::new(d.alloc_id(), NodeKind::Group { children: vec![clip, inner.clone()], clip: true }));
symbol(&mut d, "A", vec![group]);
assert_eq!(d.inline_art_bounds("A"), Some(Rect::new(-1.0, -1.0, 11.0, 11.0)));
// Without the clip, A's size depends on B's and B's on A's: both are empty slots.
let art = Arc::new(Node::group(d.alloc_id(), vec![inner]));
if let Some(a) = d.symbols.iter_mut().find(|s| s.name == "A") {
a.art = art;
}
assert_eq!(d.inline_art_bounds("A"), None);
assert_eq!(d.inline_art_bounds("B"), None);
}
#[test]
fn components_handle_a_long_chain_without_recursion() {
let n = 200_000;
+6
View File
@@ -367,6 +367,12 @@ pub struct Document {
/// saved.
#[serde(skip)]
inline_cycles: inline::InlineCycles,
/// Art kept elsewhere in the document's data (the art of the stored brushes, which
/// `vectorcraft-brush` reads from [`Self::unknown`]) while it is set here, each root with the
/// `owner` the Links panel names for it, so the stored-art lookups ([`StoredRoot::Mounted`])
/// find its objects too. Empty otherwise; never saved.
#[serde(skip)]
pub mounted_art: Vec<(Arc<Node>, serde_json::Value)>,
}
fn ppi72() -> f64 {
+9 -5
View File
@@ -248,14 +248,15 @@ fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
impl Document {
/// Visit every image object: in the layers, then in symbol definitions and pattern swatches,
/// opacity-mask art included.
/// then in [`Document::mounted_art`], opacity-mask art included.
pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) {
let roots = self
.layers
.iter()
.chain(self.symbols.iter().map(|s| &s.art))
.chain(self.graph_designs.iter().map(|g| &g.art))
.chain(self.patterns.iter().flat_map(|p| &p.art));
.chain(self.patterns.iter().flat_map(|p| &p.art))
.chain(self.mounted_art.iter().map(|(n, _)| n));
for root in roots {
walk_all(root, &mut |n| {
if let NodeKind::Image(im) = &n.kind {
@@ -384,7 +385,8 @@ impl Align {
impl Document {
/// Rewrite linked file locations in both raster images and placed VectorCraft documents,
/// including those inside symbols, patterns and opacity masks. Used to make packages portable.
/// including those inside symbols, patterns, opacity masks and [`Document::mounted_art`]. Used to
/// make packages portable.
pub fn update_links(&mut self, mut f: impl FnMut(&mut LinkInfo)) {
fn walk(n: &mut Arc<Node>, f: &mut dyn FnMut(&mut LinkInfo)) {
let n = Arc::make_mut(n);
@@ -409,7 +411,8 @@ impl Document {
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art))
.chain(self.mounted_art.iter_mut().map(|(n, _)| n));
for root in roots {
walk(root, &mut f);
}
@@ -471,7 +474,8 @@ impl Document {
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art))
.chain(self.mounted_art.iter_mut().map(|(n, _)| n));
for root in roots {
update(root, &pick, &mut f);
}
+1
View File
@@ -32,6 +32,7 @@ pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_b
pub use live_shape::{LiveShape, Scaling};
pub use opacity::{Knockout, OpacityMask, whiten};
pub use ops::{flattens, keeps_gradient_fit};
pub(crate) use ops::{held_guides, map_held, strip_held};
use wire::NodeWire;
pub(crate) use wire::counted_list;
pub use wire::{MAX_READ_NODES, with_node_budget};
+4 -4
View File
@@ -823,11 +823,11 @@ impl Node {
/// The ruler guides a layer holds: its children that are one, by index in order (each with
/// nothing inside), and its sublayers holding one (with what they hold).
#[derive(Debug, Default)]
pub(super) struct HeldGuides(Vec<(usize, HeldGuides)>);
pub(crate) struct HeldGuides(Vec<(usize, HeldGuides)>);
/// The ruler guides, by `is_guide`, that `n` holds if it is a layer, or `None` when it holds
/// none. `is_guide` looks at each child of `n` and of its sublayers once.
pub(super) fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option<HeldGuides> {
pub(crate) fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option<HeldGuides> {
if !n.is_layer() {
return None;
}
@@ -843,7 +843,7 @@ pub(super) fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> Option
/// Remove the ruler guides of `layer` and of its sublayers that [`held_guides`] found (`held`).
/// Only the sublayers holding one are copied, and only those are visited.
pub(super) fn strip_held(layer: &mut Node, held: HeldGuides) {
pub(crate) fn strip_held(layer: &mut Node, held: HeldGuides) {
let Some(ch) = layer.children_mut() else { return };
let mut held = held.0.into_iter().peekable();
let mut next = 0;
@@ -863,7 +863,7 @@ pub(super) fn strip_held(layer: &mut Node, held: HeldGuides) {
/// Call `f` (whether it is vertical, its position) on each ruler guide of `layer` and of its
/// sublayers that [`held_guides`] found (`held`), in the order they lie in. Only the sublayers
/// holding one, and the guides, are copied, and only those sublayers are visited.
pub(super) fn map_held(layer: &mut Node, held: HeldGuides, f: &mut impl FnMut(bool, &mut f64)) {
pub(crate) fn map_held(layer: &mut Node, held: HeldGuides, f: &mut impl FnMut(bool, &mut f64)) {
let Some(ch) = layer.children_mut() else { return };
for (i, inner) in held.0 {
let Some(c) = ch.get_mut(i) else { continue };
+4 -1
View File
@@ -166,13 +166,15 @@ fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
}
}
/// The roots of `d`'s art: layers, symbol definitions and pattern swatches.
/// The roots of `d`'s art: layers, symbol definitions, pattern swatches and
/// [`Document::mounted_art`].
fn roots(d: &Document) -> impl Iterator<Item = &Arc<Node>> {
d.layers
.iter()
.chain(d.symbols.iter().map(|s| &s.art))
.chain(d.graph_designs.iter().map(|g| &g.art))
.chain(d.patterns.iter().flat_map(|p| &p.art))
.chain(d.mounted_art.iter().map(|(n, _)| n))
}
fn roots_mut(d: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
@@ -181,6 +183,7 @@ fn roots_mut(d: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
.chain(d.symbols.iter_mut().map(|s| &mut s.art))
.chain(d.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(d.patterns.iter_mut().flat_map(|p| &mut p.art))
.chain(d.mounted_art.iter_mut().map(|(n, _)| n))
}
impl Document {
+57 -5
View File
@@ -20,6 +20,8 @@ pub enum StoredRoot {
GraphDesign(usize),
/// Object `art` of pattern swatch `pattern` of [`Document::patterns`].
Pattern { pattern: usize, art: usize },
/// Root `index` of [`Document::mounted_art`].
Mounted(usize),
}
/// Where a stored object is: its root and, inside opacity-mask art, the object that mask belongs
@@ -170,10 +172,12 @@ impl Document {
.iter()
.enumerate()
.flat_map(|(p, def)| def.art.iter().enumerate().map(move |(a, n)| (n, StoredRoot::Pattern { pattern: p, art: a })));
symbols.chain(designs).chain(patterns)
let mounted = self.mounted_art.iter().enumerate().map(|(i, (n, _))| (n, StoredRoot::Mounted(i)));
symbols.chain(designs).chain(patterns).chain(mounted)
}
/// Every root of stored art: the layers, then the art of symbols, graph designs and patterns.
/// Every root of stored art: the layers, then the art of symbols, graph designs and patterns,
/// then [`Document::mounted_art`].
fn stored_roots(&self) -> impl Iterator<Item = (&Arc<Node>, StoredRoot)> {
self.layers.iter().enumerate().map(|(i, l)| (l, StoredRoot::Layer(i))).chain(self.definition_roots())
}
@@ -185,12 +189,13 @@ impl Document {
StoredRoot::Symbol(i) => self.symbols.get_mut(i).map(|s| &mut s.art),
StoredRoot::GraphDesign(i) => self.graph_designs.get_mut(i).map(|g| &mut g.art),
StoredRoot::Pattern { pattern, art } => self.patterns.get_mut(pattern)?.art.get_mut(art),
StoredRoot::Mounted(i) => self.mounted_art.get_mut(i).map(|(n, _)| n),
}
}
/// Visit every stored object with its place: the layer tree with the opacity-mask art of its
/// objects, then the art of symbols, graph designs and pattern swatches (their definitions,
/// once each, never the copies instances and tiles draw).
/// once each, never the copies instances and tiles draw), then [`Document::mounted_art`].
pub fn visit_stored<'a>(&'a self, mut f: impl FnMut(&'a Node, StoredPlace)) {
self.visit_stored_arcs(|n, place| f(n, place));
}
@@ -238,6 +243,29 @@ impl Document {
/// visited before them (the layer tree's own objects first) a new id, so every stored object
/// can be named by its id. Returns how many changed; the id counter then stands above them.
pub(crate) fn renumber_stored_duplicates(&mut self) -> usize {
self.renumber_duplicates_in(|_| true)
}
/// Raises the id counter above the ids of [`Document::mounted_art`]'s objects, then gives those
/// objects whose id is 0, past the id range, or already taken by an object visited before them
/// (every other stored object first) a new id, as [`Self::renumber_stored_duplicates`] gives
/// them. Returns how many changed.
pub fn number_mounted_art(&mut self) -> usize {
let mut max = 0;
for (i, (root, _)) in self.mounted_art.iter().enumerate() {
visit(root, StoredPlace { root: StoredRoot::Mounted(i), mask_of: None }, &mut |n, _| {
if n.id.0 < MAX_NEXT_ID {
max = max.max(n.id.0);
}
});
}
self.raise_next_id(max.saturating_add(1));
self.renumber_duplicates_in(|r| matches!(r, StoredRoot::Mounted(_)))
}
/// [`Self::renumber_stored_duplicates`] for the roots `pick` holds for only; the objects of the
/// others still count as taken.
fn renumber_duplicates_in(&mut self, pick: impl Fn(StoredRoot) -> bool) -> usize {
let mut seen: HashSet<NodeId> = HashSet::new();
for l in &self.layers {
l.walk(&mut |n| {
@@ -250,9 +278,9 @@ impl Document {
// Per root, the visiting indices of the nodes that take a new id.
let mut fresh: Vec<(StoredRoot, HashSet<usize>)> = vec![];
for (root, r) in self.stored_roots() {
let (mut i, mut here) = (0, HashSet::new());
let (mut i, mut here, take) = (0, HashSet::new(), pick(r));
visit(root, StoredPlace { root: r, mask_of: None }, &mut |n, place| {
if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) {
if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) && take {
here.insert(i);
}
i += 1;
@@ -355,6 +383,30 @@ mod tests {
assert_eq!(d, again);
}
/// Mounted art is found and changed by id after the other stored art, and numbering it gives
/// its objects that repeat another stored object's id, or have none, new ids above the counter,
/// leaving every other object's id and its own unique ones as they are.
#[test]
fn mounted_art_is_found_and_numbered_after_the_rest() {
let mut d = doc([12, 13, 14]);
let owner = serde_json::json!({ "kind": "brush" });
d.mounted_art = vec![(Arc::new(masked(10, rect(0))), owner.clone()), (Arc::new(rect(15)), owner)];
assert_eq!(d.stored_node(NodeId(15)).map(|(_, p)| p.root), Some(StoredRoot::Mounted(1)));
let before = d.clone();
assert_eq!(d.number_mounted_art(), 2);
let mut ids = vec![];
d.visit_stored(|n, _| ids.push(n.id.0));
let unique: HashSet<u64> = ids.iter().copied().collect();
assert_eq!(unique.len(), ids.len(), "{ids:?}");
assert_eq!(&ids[..6], &[d.layers[0].id.0, 10, 11, 12, 13, 14], "the other art keeps its ids");
assert_eq!(ids[8], 15, "a unique id is kept");
assert!(ids.iter().all(|id| *id < d.peek_next_id()), "{ids:?} {}", d.peek_next_id());
assert!(Arc::ptr_eq(&d.symbols[0].art, &before.symbols[0].art) && Arc::ptr_eq(&d.mounted_art[1].0, &before.mounted_art[1].0));
d.stored_node_mut(NodeId(15)).unwrap().name = Some("changed".into());
assert_eq!(d.mounted_art[1].0.name.as_deref(), Some("changed"));
assert_eq!(d.number_mounted_art(), 0, "a second pass changes nothing");
}
#[test]
fn renumbered_objects_on_path_keep_their_path_and_members() {
// A symbol's Objects on Path group whose path repeats the layer's object 10 and whose
+86 -27
View File
@@ -2,7 +2,7 @@
//! the document and measures it once, and an instance's bounds are its art's box placed by the
//! instance's transform ([`Node::geometric_bounds_with`]).
use std::cell::{Cell, RefCell};
use std::cell::{Cell, OnceCell, RefCell};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -26,6 +26,9 @@ const MAX_REACHES: usize = 4096;
/// A symbol's name and the bits of the linear part of a transform that places its art.
type Placing = (String, [u64; 4]);
/// Each symbol name of a document → the index of the first symbol of that name.
pub(crate) type SymbolNames = HashMap<String, usize>;
/// A symbol's art measured in symbol space.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ArtBox {
@@ -51,6 +54,10 @@ pub(crate) struct ArtBox {
/// symbols change.
pub struct SymbolBoxes<'a> {
doc: &'a Document,
/// Each symbol name of the document → the index of the first symbol of that name (the one a
/// name finds), made the first time a name is looked up ([`Self::symbol_art`]) or given by
/// [`Self::with_names`].
names: OnceCell<Arc<SymbolNames>>,
/// The art boxes measured so far, by symbol name (`None` for a symbol whose instances are
/// measured by [`SYMBOL_SQUARE`]).
measured: RefCell<HashMap<String, Option<ArtBox>>>,
@@ -89,6 +96,9 @@ pub struct SymbolBoxes<'a> {
sliced_most: u64,
/// The widths [`Self::measured_width`] has measured, by kind and symbol name.
widths: RefCell<HashMap<(&'static str, String), Option<f64>>>,
/// While [`Self::consulted`] runs: the symbols whose art boxes [`Self::art`] has given outside
/// the measuring of symbol art.
consulted: RefCell<Option<HashSet<String>>>,
}
impl Document {
@@ -96,6 +106,7 @@ impl Document {
pub fn symbol_boxes(&self) -> SymbolBoxes<'_> {
SymbolBoxes {
doc: self,
names: OnceCell::new(),
measured: RefCell::default(),
depths: RefCell::default(),
quick: Cell::new(false),
@@ -110,8 +121,19 @@ impl Document {
sliced_placed: Cell::new(0),
sliced_most: MAX_SYMBOL_NODES,
widths: RefCell::default(),
consulted: RefCell::default(),
}
}
/// Each symbol name of this document → the index of the first symbol of that name (the one a
/// name finds).
pub(crate) fn symbol_names(&self) -> SymbolNames {
let mut names = HashMap::with_capacity(self.symbols.len());
for (i, s) in self.symbols.iter().enumerate() {
names.entry(s.name.clone()).or_insert(i);
}
names
}
}
impl<'a> SymbolBoxes<'a> {
@@ -120,6 +142,26 @@ impl<'a> SymbolBoxes<'a> {
self.doc
}
/// Makes these boxes find symbols by `names` ([`Document::symbol_names`]), made from the
/// document's symbols while they had the names and order they have now.
pub(crate) fn with_names(self, names: &Arc<SymbolNames>) -> Self {
let _ = self.names.set(names.clone());
self
}
/// The art of the first symbol of the document named `name`, found in time independent of the
/// number of symbols after the first lookup.
pub(crate) fn symbol_art(&self, name: &str) -> Option<&'a Arc<Node>> {
let doc: &'a Document = self.doc;
let names = self.names.get_or_init(|| Arc::new(doc.symbol_names()));
let i = *names.get(name)?;
match doc.symbols.get(i) {
Some(s) if s.name == name => Some(&s.art),
// Names that no longer match the symbols: the first symbol of that name.
_ => doc.symbols.iter().find(|s| s.name == name).map(|s| &s.art),
}
}
/// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its
/// art box alone. An instance that scales or turns stroked art then measures a little larger
/// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it. The
@@ -160,21 +202,37 @@ impl<'a> SymbolBoxes<'a> {
/// art boxes. Returns `None` when [`Self::depth`] is `None` and when the art has no finite
/// bounds: instances of the symbol are then measured by [`SYMBOL_SQUARE`].
pub(crate) fn art(&self, name: &str) -> Option<ArtBox> {
if let Some(b) = self.measured.borrow().get(name) {
return *b;
let cached = self.measured.borrow().get(name).copied();
let b = cached.unwrap_or_else(|| {
let b = self.measure_art(name, |art| self.in_art(|| art_box(art, self))).flatten();
self.measured.borrow_mut().insert(name.to_string(), b);
b
});
if b.is_some()
&& let Some(seen) = self.consulted.borrow_mut().as_mut()
&& !seen.contains(name)
{
seen.insert(name.to_string());
}
let b = self.measure_art(name, |art| self.in_art(|| art_box(art, self))).flatten();
self.measured.borrow_mut().insert(name.to_string(), b);
b
}
/// Runs `measure`, and returns the names of the symbols whose art boxes it took from these
/// boxes, the art of symbols it measured left out: the symbols whose art the instances it
/// measured are measured by.
pub(crate) fn consulted(&self, measure: impl FnOnce()) -> HashSet<String> {
let was = self.consulted.replace(Some(HashSet::new()));
measure();
self.consulted.replace(was).unwrap_or_default()
}
/// Runs `measure` on the art of the symbol named `name`. Returns `None` when [`Self::depth`]
/// is `None`, without running it.
pub fn measure_art<R>(&self, name: &str, measure: impl FnOnce(&Arc<Node>) -> R) -> Option<R> {
self.depth(name)?;
let sym = self.doc.symbols.iter().find(|s| s.name == name)?;
let art = self.symbol_art(name)?;
self.runs.set(self.runs.get() + 1);
Some(measure(&sym.art))
Some(measure(art))
}
/// How many times [`Self::measure_art`] has run a measure on symbol art for these boxes, the
@@ -297,10 +355,9 @@ impl<'a> SymbolBoxes<'a> {
/// other symbols it reaches: the depth of each one it walks to the end, and `None` for those on
/// the way down to a symbol that holds an instance of itself or nests too deep.
fn find_depth(&self, name: &str, learned: &mut HashMap<String, Option<usize>>) -> Option<usize> {
let art = |n: &str| self.doc.symbols.iter().find(|s| s.name == n).map(|s| &s.art);
// The symbols on the way down from `name`: each with the symbols its art shows that are
// still to be walked, and the deepest nesting found under it so far.
let mut path: Vec<(String, Vec<String>, usize)> = vec![(name.to_string(), shown(self.doc, art(name)?), 0)];
let mut path: Vec<(String, Vec<String>, usize)> = vec![(name.to_string(), self.shown(self.symbol_art(name)?), 0)];
loop {
let (_, todo, _) = path.last_mut()?;
let Some(next) = todo.pop() else {
@@ -330,8 +387,8 @@ impl<'a> SymbolBoxes<'a> {
// Symbols nested MAX_DEPTH deep under `name`.
None if path.len() >= MAX_DEPTH => return None,
None => {
if let Some(a) = art(&next) {
let todo = shown(self.doc, a);
if let Some(a) = self.symbol_art(&next) {
let todo = self.shown(a);
path.push((next, todo, 0));
}
}
@@ -379,7 +436,7 @@ impl<'a> SymbolBoxes<'a> {
None => {
// The most objects placing makes, the art of opacity masks included, is set aside
// while the art is placed and measured, and what placing did not make is given back.
let art = &self.doc.symbols.iter().find(|s| s.name == name)?.art;
let art = self.symbol_art(name)?;
let most = match self.doc.symbol_slice_map(name, linear) {
Some(m) => m.most_placed(art, self.doc),
None => art.count_with_masks() as u64,
@@ -431,11 +488,28 @@ impl<'a> SymbolBoxes<'a> {
fn in_art<R>(&self, f: impl FnOnce() -> R) -> R {
let pad = self.brush_art.replace(false);
let strokes = self.art_strokes.replace(true);
let consulted = self.consulted.take();
let r = self.quickly(f);
self.brush_art.set(pad);
self.art_strokes.set(strokes);
*self.consulted.borrow_mut() = consulted;
r
}
/// The names of the symbols of the document that the instances in `art` show, each once.
fn shown(&self, art: &Node) -> Vec<String> {
let mut seen = HashSet::new();
let mut names = vec![];
art.walk(&mut |n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
&& seen.insert(symbol.as_str())
&& self.symbol_art(symbol).is_some()
{
names.push(symbol.clone());
}
});
names
}
}
/// Records every symbol on `path` in `learned` as one whose instances are measured by
@@ -447,21 +521,6 @@ fn give_up(path: &[(String, Vec<String>, usize)], learned: &mut HashMap<String,
None
}
/// The names of the symbols of `doc` that the instances in `art` show, each once.
fn shown(doc: &Document, art: &Node) -> Vec<String> {
let mut seen = HashSet::new();
let mut names = vec![];
art.walk(&mut |n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
&& seen.insert(symbol.as_str())
&& doc.symbols.iter().any(|s| s.name == *symbol)
{
names.push(symbol.clone());
}
});
names
}
/// Measures symbol art `art`, with the instances in it measured by `symbols`.
fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite());
+1 -1
View File
@@ -18,7 +18,7 @@ mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
WARICHU_SCALE, WarichuAlign,
};
pub use composite::{
+15 -1
View File
@@ -44,6 +44,14 @@ pub struct CharStyle {
/// Insert Aki Right: space added after the character in reading order, as [`Self::aki_left`].
#[serde(default, rename = "akiRight", skip_serializing_if = "crate::skip::is_default")]
pub aki_right: f64,
/// Tsume (Character panel, East Asian options): the percentage of the blank space on each side
/// of the character's ink, inside its own width, that is taken away, 0 to [`MAX_TSUME`]. 50
/// halves that space and 100 sets the ink edge to edge; the glyph itself is not scaled. A
/// cluster takes the value of its first character. Tracking, kerning and Insert Aki are added
/// after it. Horizontal type only.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub tsume: f64,
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
#[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")]
pub optical_kerning: bool,
@@ -440,6 +448,9 @@ fn hundred() -> f64 {
/// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em.
pub const MAX_AKI: f64 = 1000.0;
/// The largest Tsume ([`CharStyle::tsume`]), in percent: all the blank space beside the ink.
pub const MAX_TSUME: f64 = 100.0;
/// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]).
pub const WARICHU_LINES: std::ops::RangeInclusive<u8> = 1..=6;
@@ -534,6 +545,7 @@ impl Default for CharStyle {
kerning_before: 0.0,
aki_left: 0.0,
aki_right: 0.0,
tsume: 0.0,
optical_kerning: false,
baseline_shift: 0.0,
h_scale: 100.0,
@@ -588,7 +600,8 @@ impl CharStyle {
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right
/// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to
/// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale
/// [`MAX_AKI`], and one that is not a number becomes 0; Tsume is kept from 0 to [`MAX_TSUME`] the
/// same way. The Warichu Settings Lines and Scale
/// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to
/// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its
/// own units.
@@ -606,6 +619,7 @@ impl CharStyle {
let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) };
self.aki_left = aki(self.aki_left);
self.aki_right = aki(self.aki_right);
self.tsume = if self.tsume.is_nan() { 0.0 } else { self.tsume.clamp(0.0, MAX_TSUME) };
self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end());
self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) };
self.warichu_scale =
+2
View File
@@ -507,6 +507,7 @@ impl crate::Document {
st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right);
st.aki_left = num("akiLeft").unwrap_or(st.aki_left);
st.aki_right = num("akiRight").unwrap_or(st.aki_right);
st.tsume = num("tsume").unwrap_or(st.tsume);
st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap);
st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale);
st.keep_dimensions_in_range();
@@ -532,6 +533,7 @@ impl crate::Document {
("tcyLeftRight", Some(st.tcy_left_right)),
("akiLeft", Some(st.aki_left)),
("akiRight", Some(st.aki_right)),
("tsume", Some(st.tsume)),
("leading", st.leading),
];
for (k, v) in kept {
+2 -1
View File
@@ -357,7 +357,7 @@ impl ParaList {
/// The style the marker is set in: `first` (the style of the paragraph's first character)
/// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning,
/// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough.
/// Insert Aki, Tsume, tate-chu-yoko, warichu, underline and strikethrough.
pub fn marker_style(first: &CharStyle) -> CharStyle {
CharStyle {
baseline_shift: 0.0,
@@ -368,6 +368,7 @@ impl ParaList {
kerning_before: 0.0,
aki_left: 0.0,
aki_right: 0.0,
tsume: 0.0,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
warichu: false,
+28
View File
@@ -129,6 +129,34 @@ fn insert_aki_is_saved_only_when_set() {
assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0)));
}
/// Tsume is 0 by default and saved only when not 0; styles written before it read as 0, and a
/// value out of range from a file, in a run or a character style, is kept from 0 to 100.
#[test]
fn tsume_is_saved_only_when_set_and_kept_in_range() {
let st = CharStyle::default();
assert_eq!(st.tsume, 0.0);
let saved = serde_json::to_value(&st).unwrap();
assert!(saved.get("tsume").is_none());
let read = serde_json::from_value::<CharStyle>(saved).unwrap();
assert_eq!(read.tsume, 0.0);
let set = CharStyle { tsume: 37.5, ..CharStyle::default() };
let saved = serde_json::to_value(&set).unwrap();
assert_eq!(saved.get("tsume").and_then(serde_json::Value::as_f64), Some(37.5));
assert_eq!(serde_json::from_value::<CharStyle>(saved).unwrap(), set);
for (stored, kept) in [(-5.0, 0.0), (250.0, MAX_TSUME), (f64::NAN, 0.0), (f64::INFINITY, MAX_TSUME), (50.0, 50.0)] {
let mut damaged = CharStyle { tsume: stored, ..CharStyle::default() };
damaged.keep_dimensions_in_range();
assert_eq!(damaged.tsume, kept, "{stored}");
}
let mut d = crate::Document::new(100.0, 100.0);
for tsume in [500, -1] {
let attrs = serde_json::json!({ "tsume": tsume });
d.char_styles.push(TextStyleDef { name: format!("Tight {tsume}"), attrs: attrs.as_object().cloned().unwrap_or_default() });
}
d.keep_type_values_in_range();
assert_eq!((d.char_styles[0].attrs["tsume"].as_f64(), d.char_styles[1].attrs["tsume"].as_f64()), (Some(MAX_TSUME), Some(0.0)));
}
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
+83 -9
View File
@@ -26,20 +26,20 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
use crate::distort::{transform_move, transform_offsets};
use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::reshape::{as_art, clipping_text_outline, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::{
GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
match item {
AppearanceItem::Fill(f) => &f.effects,
AppearanceItem::Stroke(s) => &s.effects,
}
}
fn clear_item_effects(item: &mut AppearanceItem) {
pub(crate) fn clear_item_effects(item: &mut AppearanceItem) {
match item {
AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects),
AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects),
@@ -74,7 +74,7 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
/// `path` with the geometry effects of `effects` applied, and how gradients follow them
/// ([`crate::Carry`]).
fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) {
pub(crate) fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) {
if !has_geometry(effects) || path.is_empty() {
return (path.clone(), crate::Carry::default());
}
@@ -86,7 +86,7 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext
/// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient
/// 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) {
pub(crate) 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) {
@@ -201,8 +201,26 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
{
return bake_leaf(d, n);
}
if let Some(m) = clip_group_with_type_clip(d, n) {
return Some(m);
}
let mut m = n.clone();
let baked: Vec<Arc<Node>> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect();
// A clipping path takes the geometry effects in it as the canvas clips by it, those of a
// compound path's paths before the compound path's own ([`crate::clip::with_clip_effects_applied`]);
// the pieces it is made of then get ids of their own.
let clip = usize::from(n.clips());
let baked: Vec<Arc<Node>> = ch
.iter()
.enumerate()
.map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() {
Some(a) => {
let mut a = bake_node(d, &a).unwrap_or(a);
fresh_ids(d, &mut a, &mut std::collections::HashSet::new());
Arc::new(a)
}
None => bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()),
})
.collect();
if let Some(ch) = m.children_mut() {
*ch = baked;
}
@@ -211,6 +229,62 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
bake_leaf(d, n)
}
/// Clip group or clipping layer `n` whose clipping path is type with geometry effects (its own, its
/// fills' or strokes', or a perspective), baked as the canvas draws it: the type becomes a
/// clipping path of the region the canvas clips to ([`crate::clip_outline`]), its fills (with their
/// effects baked) paint behind the clipped art inside the clip, and its strokes paint over it
/// outside the clip, in a group that `n` becomes with its clip group inside ([`Node::clip_paint`]).
/// The other members are baked as usual. `None` for other objects and when the type has nothing to
/// clip by.
fn clip_group_with_type_clip(d: &mut Document, n: &Node) -> Option<Node> {
let (first, rest) = n.children()?.split_first()?;
if !n.clips()
|| !matches!(first.kind, NodeKind::Text(_))
|| !(has_geometry(&first.appearance.effects) || first.projection().is_some() || has_item_geometry(first))
{
return None;
}
let (bp, rule) = crate::clip_outline(first)?;
let mut clip = Node::path(first.id, PathData::from_bezpath(&bp), Appearance::default());
if let NodeKind::Path { rule: r, clipping, .. } = &mut clip.kind {
(*r, *clipping) = (rule, true);
}
clip.name = first.name.clone();
clip.visible = first.visible;
let paint = first.clip_paint();
// The type's fills and strokes as art: baked with the type's id, as the renderer draws them,
// then given ids of their own.
let mut seen = std::collections::HashSet::from([n.id, first.id]);
let mut art = |d: &mut Document, part: Option<Node>| {
part.map(|p| {
let mut a = bake_node(d, &p).unwrap_or(p);
fresh_ids(d, &mut a, &mut seen);
Arc::new(a)
})
};
let fill = art(d, paint.fill);
let stroke = art(d, paint.stroke);
let members: Vec<Arc<Node>> =
std::iter::once(Arc::new(clip)).chain(fill).chain(rest.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()))).collect();
let mut m = n.clone();
let Some(stroke) = stroke else {
if let Some(ch) = m.children_mut() {
*ch = members;
}
return Some(m);
};
// `n` keeps its id, name and transparency, and holds the clip group and the strokes over it.
let inner = Node::new(d.alloc_id(), NodeKind::Group { children: members, clip: true });
match &mut m.kind {
NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } => {
*children = vec![Arc::new(inner), stroke];
*clip = false;
}
_ => return None,
}
Some(m)
}
/// `m`, art evaluated from an object, with its children baked and then given ids of their own (the
/// pieces copied from the source keep the source's ids until then). The pieces are baked with the
/// ids the renderer draws them with: Random in a Transform effect takes its seed from the id.
@@ -407,7 +481,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())),
}
}
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
None if clipping => clipping_text_outline(c),
None => as_art(Some(d), c, None),
};
if let Some(o) = art {
@@ -469,7 +543,7 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
/// its effects, and `n` without those effects, its gradients moved along with them.
fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
pub(crate) fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
let (base, rule) = geometry(n)?;
let ctx = GeomContext::of(n);
let mut m = n.clone();
+54 -4
View File
@@ -2,7 +2,7 @@ use super::*;
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use vectorcraft_geom::{Point, Rect, Shape, shapes};
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
fn skip_without_craft_fonts() -> bool {
@@ -793,7 +793,7 @@ fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
}
#[test]
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
fn type_in_a_clipping_group_with_a_fill_of_its_own_keeps_its_effect() {
if skip_without_craft_fonts() {
return;
}
@@ -808,9 +808,59 @@ fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
g.children_mut().unwrap().push(Arc::new(clip));
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
// The type clips by its plain outlines, as the canvas clips by them: its own effect is left out.
// The type clips by its outlines moved by its own effect, as the canvas clips by them.
let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}");
assert!(b.y0 > 330.0 && b.y1 < 370.0, "{b:?}");
}
#[test]
fn clipping_type_whose_fill_has_a_geometry_effect_clips_by_its_plain_outlines() {
if skip_without_craft_fonts() {
return;
}
let near = |a: Rect, b: Rect, tol: f64| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < tol);
for stroke in [Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0))] {
// Type that clips a square, with a Transform effect on its own fill.
let mut t = type_node(60);
t.appearance = Appearance::basic(Paint::solid(Color::BLACK), stroke.clone(), 1.0).into();
for item in &mut t.appearance.items {
if let AppearanceItem::Fill(f) = item {
f.effects.push(fx("distort.transform", json!({"moveH": 300})));
}
}
let square = Node::path(NodeId(61), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
let clip = Node::new(NodeId(62), NodeKind::Group { children: vec![Arc::new(t.clone()), Arc::new(square)], clip: true });
let canvas = crate::clip_outline(&t).map(|(bp, _)| bp.bounding_box()).unwrap();
assert!(canvas.x1 < 100.0, "the canvas clips by the plain outlines: {canvas:?}");
let d = bake_document(&doc_with(clip)).unwrap();
let baked = d.node(NodeId(62)).unwrap();
// With a stroke, the clip group sits in a group with the stroke over it, outside the clip.
let group = match baked.kind {
NodeKind::Group { clip: true, .. } => {
assert!(stroke.is_none(), "{baked:?}");
baked
}
_ => {
let ch = baked.children().unwrap();
assert_eq!(ch.len(), 2, "{baked:?}");
let strokes = ch[1].appearance.items.iter().all(|i| matches!(i, AppearanceItem::Stroke(_)));
assert!(matches!(ch[1].kind, NodeKind::Text(_)) && strokes, "the type with only its stroke: {:?}", ch[1]);
&ch[0]
}
};
let ch = group.children().unwrap();
assert!(matches!(group.kind, NodeKind::Group { clip: true, .. }) && ch.len() == 3, "{group:?}");
// The export clips by the outlines the canvas clips by.
assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0]);
let b = crate::clip_outline(&ch[0]).map(|(bp, _)| bp.bounding_box()).unwrap();
assert!(near(b, canvas, 1e-6), "canvas {canvas:?}, export {b:?}");
// The fills (the characters' and the moved one) paint behind the square, inside the clip.
let fill = ch[1].geometric_bounds().unwrap();
assert!((fill.x0 - canvas.x0).abs() < 1e-6 && fill.x1 > canvas.x1 + 250.0, "{fill:?}");
assert_eq!(ch[2].id, NodeId(61));
let mut ids = std::collections::HashSet::new();
baked.walk(&mut |n| assert!(ids.insert(n.id), "duplicate id {}", n.id));
}
}
#[test]
+117 -2
View File
@@ -7,9 +7,124 @@ use vectorcraft_geom::{BezPath, FillRule, PathData};
/// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their
/// holes, even-odd paths their rule, text clips by its glyph outlines and a group by the union of
/// its members. `None` when there is nothing to clip by (the clipped art is then hidden).
/// its members. The live geometry effects of the clipping path (and of the members it clips by)
/// reshape the region, as they reshape what it paints, and so do the geometry effects of a path's
/// fills and strokes: the region is then the union of the paths they give, as an export bakes them.
/// `None` when there is nothing to clip by (the clipped art is then hidden).
pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
clip.clip_outline(Some(&outline_text), &unite)
match clip_effects_applied(clip, true) {
Some(m) => m.clip_outline(Some(&outline_text), &unite),
None => clip.clip_outline(Some(&outline_text), &unite),
}
}
/// `n`, the clipping path of a clip group, with the live geometry effects of everything it clips by
/// applied, innermost first, and removed: a path or compound path with effects becomes a path of
/// its reshaped geometry that keeps its fills, strokes and other effects (its gradients follow the
/// effects, as an export bakes it), the paths of a compound path and the visible members of a group
/// are reshaped by their own effects before the effects around them, a group's or layer's own
/// effects reshape its members as an export evaluates them, and other objects with effects (type)
/// become a path of their reshaped region. A path or compound path whose fills or strokes have
/// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects
/// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their
/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the
/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a
/// geometry effect. Effects on the clip group or around it then reshape the result, after its own.
pub(crate) fn with_clip_effects_applied(n: &Node) -> Option<Node> {
let paint = n.clip_paint();
clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none())
}
/// [`with_clip_effects_applied`]; with `items` unset (a clipping path that paints, and the paths of
/// a compound path, which draw with the compound path's fills and strokes), the geometry effects of
/// `n`'s own fills and strokes are left out.
fn clip_effects_applied(n: &Node, items: bool) -> Option<Node> {
let mut m: Option<Node> = None;
let members: &[std::sync::Arc<Node>] = match &n.kind {
NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children,
_ => &[],
};
// A group or layer clips by its visible members after its shaper; a compound path by all of
// its paths, and a nested clip group by its clipping path, shown or hidden
// ([`Node::clip_shapes`]).
let all = matches!(n.kind, NodeKind::Compound { .. } | NodeKind::Group { clip: true, .. });
let skip = usize::from(matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) && n.extras().shaper.is_some());
// The paths of a compound path draw with the compound path's fills and strokes, and stay paths.
let compound = matches!(n.kind, NodeKind::Compound { .. });
for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) {
if let Some(r) = clip_effects_applied(c, items && !compound)
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
{
*slot = std::sync::Arc::new(r);
}
}
let items = items
&& matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
&& n.appearance.items.iter().any(|i| crate::has_geometry(crate::bake::item_effects(i)));
if items && let Some((g, rule, ctx, mut p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) {
let children = n
.appearance
.items
.iter()
.zip(&p.appearance.items)
.map(|(item, carried)| {
let (ig, mut it) = crate::bake::apply_item(item, carried, &g, &ctx);
crate::bake::clear_item_effects(&mut it);
let mut piece = Node::path(n.id, ig, Appearance { items: vec![it], ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut piece.kind {
*r = rule;
}
std::sync::Arc::new(piece)
})
.collect();
// The path's transparency and raster effects stay on the group of its pieces.
p.appearance.items.clear();
p.kind = NodeKind::Group { children, clip: false };
return Some(p);
}
if !crate::has_geometry(&n.appearance.effects) {
return m;
}
let cur = m.as_ref().unwrap_or(n);
// A group's or layer's effects reshape each of its members (a clip group's its clipping
// path), as an export evaluates them; otherwise they reshape its region, below.
if matches!(cur.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
&& let Some(e) = crate::group::evaluate_container(None, cur)
{
return Some(e);
}
// A path or compound path, as an export bakes it.
if let Some((g, rule, _, mut p)) = crate::bake::leaf_base(cur) {
let clipping = match &cur.kind {
NodeKind::Path { clipping, .. } => *clipping,
NodeKind::Compound { children, .. } => children.iter().any(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })),
_ => false,
};
p.kind = Node::path(n.id, g, Appearance::default()).kind;
if let NodeKind::Path { rule: r, clipping: c, .. } = &mut p.kind {
(*r, *c) = (rule, clipping);
}
return Some(p);
}
// Type, as its painted outlines that its effects reshape as an export evaluates them.
if matches!(cur.kind, NodeKind::Text(_))
&& let Some(mut o) = crate::outline_text(cur)
{
o.visible = n.visible;
o.appearance.effects = n.appearance.effects.clone();
if let Some(e) = crate::group::evaluate_container(None, &o) {
return Some(e);
}
}
let (bp, rule) = cur.clip_outline(Some(&outline_text), &unite)?;
let (g, _) = crate::bake::apply(&n.appearance.effects, &PathData::from_bezpath(&bp), &crate::GeomContext::of(n));
let mut p = Node::path(n.id, g, Appearance::default());
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
*r = rule;
}
p.visible = n.visible;
Some(p)
}
/// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque
+107 -34
View File
@@ -661,8 +661,9 @@ pub fn has_container_appearance(n: &Node) -> bool {
/// The regions a group's or layer's own fills and strokes paint, in document space, each with its
/// fill rule: every visible member path and compound path, type as its glyph outlines and live
/// objects evaluated; a compound shape as its area, with the symbol instances among its members
/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its
/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the
/// as their symbols' art from `doc`; a clip group or clipping layer (`n` itself or one among its
/// members) as the region its clipping path clips to ([`crate::clip::clip_outline`]), so members
/// it hides add nothing. Images, other symbol instances and guides add nothing. A Repeat counts the
/// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> {
fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
@@ -690,8 +691,14 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
}
}
fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
let clip = usize::from(n.clips());
for c in n.children().into_iter().flatten().skip(clip) {
// A clip group shows its members only inside its clipping path: its outline is that
// region, not the clipped members' own shapes, which may reach far outside it (#7289).
if n.clips() {
let clip = n.children().and_then(|ch| ch.first());
out.extend(clip.and_then(|c| crate::clip::clip_outline(c)).map(|(bp, rule)| (PathData::from_bezpath(&bp), rule)));
return;
}
for c in n.children().into_iter().flatten() {
push(doc, c, out);
}
}
@@ -734,35 +741,93 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)],
Some(g)
}
/// Whether a sublayer among the members of `n`, or among its groups' members, holds a ruler guide.
fn sublayer_guides(n: &Node) -> bool {
n.children().is_some_and(|ch| ch.iter().any(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))))
/// The members of a container that [`take_held_guides`] visits, by index, each with the same for
/// its own members. A ruler guide's entry is empty; a container's is not.
#[derive(Default)]
struct HeldGuides(Vec<(usize, HeldGuides)>);
/// The members of `n` holding or leading to the ruler guides of sublayers, found by looking at each
/// member at every depth once: the ruler guides (`is_guide`) of `n` when it is a layer, each
/// sublayer holding one directly or in its sublayers, and each member of another kind with such a
/// sublayer among its members or its groups' members. With them, whether `n` is a layer holding a
/// ruler guide directly or in its sublayers. The other members of a layer holding none are not
/// looked into, unless the layer is the one whose sublayers are gathered (`top`).
fn held_guides(n: &Node, top: bool, is_guide: &impl Fn(&Node) -> bool) -> (bool, HeldGuides) {
let layer = n.is_layer();
let (mut holds, mut held, mut other) = (false, vec![], vec![]);
for (i, c) in n.children().into_iter().flatten().enumerate() {
if is_guide(c) {
if layer {
holds = true;
held.push((i, HeldGuides::default()));
}
} else if c.is_layer() {
let (sub_holds, inner) = held_guides(c, false, is_guide);
if sub_holds {
holds = true;
held.push((i, inner));
}
} else {
other.push((i, c));
}
}
if !layer || holds || top {
for (i, c) in other {
let (_, inner) = held_guides(c, false, is_guide);
if !inner.0.is_empty() {
held.push((i, inner));
}
}
held.sort_unstable_by_key(|(i, _)| *i);
}
(layer && holds, HeldGuides(held))
}
/// Move the ruler guides of `n` and of its members that [`held_guides`] found (`held`) to `out`:
/// those of `n` first, in order, then those of each member in turn. A guide taken from a hidden or
/// locked container, or from one inside such a container, is hidden or locked itself. Only the
/// containers in `held` are copied, and only those are visited.
fn take_held_guides(n: &mut Node, held: HeldGuides, out: &mut Vec<Arc<Node>>, hidden: bool, locked: bool) {
let Some(ch) = n.children_mut() else { return };
let (mut own, mut deeper) = (vec![], vec![]);
for (i, inner) in held.0 {
if inner.0.is_empty() {
own.push(i);
continue;
}
let Some(c) = ch.get_mut(i) else { continue };
let c = Arc::make_mut(c);
let (hidden, locked) = (hidden || !c.visible, locked || c.locked);
take_held_guides(c, inner, &mut deeper, hidden, locked);
}
if !own.is_empty() {
let mut own = own.into_iter().peekable();
for (i, mut c) in std::mem::take(ch).into_iter().enumerate() {
if own.next_if_eq(&i).is_none() {
ch.push(c);
continue;
}
if hidden || locked {
let g = Arc::make_mut(&mut c);
g.visible &= !hidden;
g.locked |= locked;
}
out.push(c);
}
}
out.append(&mut deeper);
}
/// Move the ruler guides of the sublayers among the members of `n`, and among its groups'
/// members, to `out`, in order. A guide taken from a hidden or locked sublayer, or from one inside
/// such a sublayer or group, is hidden or locked itself. Only containers holding one are copied.
fn take_sublayer_guides(n: &mut Node, out: &mut Vec<Arc<Node>>, hidden: bool, locked: bool) {
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut().filter(|c| c.holds_ruler_guides() || (!c.is_layer() && sublayer_guides(c))) {
let c = Arc::make_mut(c);
let (hidden, locked) = (hidden || !c.visible, locked || c.locked);
if c.is_layer()
&& let Some(lc) = c.children_mut()
{
let (taken, art): (Vec<_>, Vec<_>) = std::mem::take(lc).into_iter().partition(|g| g.is_ruler_guide());
*lc = art;
out.extend(taken.into_iter().map(|mut g| {
if hidden || locked {
let g = Arc::make_mut(&mut g);
g.visible &= !hidden;
g.locked |= locked;
}
g
}));
}
take_sublayer_guides(c, out, hidden, locked);
}
/// members, to `out`, in order: a sublayer's own guides, then those of its members. A guide taken
/// from a hidden or locked sublayer, or from one inside such a sublayer or group, is hidden or
/// locked itself. Each member at every depth is looked at once at most (`is_guide`), and only
/// containers holding one are copied.
fn take_sublayer_guides(n: &mut Node, out: &mut Vec<Arc<Node>>, is_guide: &impl Fn(&Node) -> bool) {
let (_, mut held) = held_guides(n, true, is_guide);
// The ruler guides of `n` itself stay where they are.
held.0.retain(|(_, inner)| !inner.0.is_empty());
take_held_guides(n, held, out, false, false);
}
/// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects
@@ -790,7 +855,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
// So do its sublayers' guides, which would otherwise go with the art the effects replace or
// copy: they stay on this layer, hidden and locked as their sublayers were.
if m.is_layer() && (pathfinder || !fx.is_empty()) {
take_sublayer_guides(&mut m, &mut guides, false, false);
take_sublayer_guides(&mut m, &mut guides, &Node::is_ruler_guide);
}
if pathfinder {
let hook = |c: &Node| crate::outline_object(doc, c);
@@ -838,9 +903,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
/// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every
/// depth of its groups and layers, by the art it draws ([`evaluate_container`],
/// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members
/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the
/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with
/// everything in it. Calls nested in one another, through the
/// draw. A clip group's clipping path only takes the geometry effects in it into its geometry
/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects
/// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D
/// effect with everything in it. Calls nested in one another, through the
/// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members
/// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at
/// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are.
@@ -848,6 +914,13 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) {
let Some(_frame) = DrawnFrame::enter() else { return };
let clip = usize::from(n.clips());
let Some(ch) = n.children_mut() else { return };
// The clipping path's own geometry effects shape it before the effects around it do.
if clip == 1
&& let Some(first) = ch.first_mut()
&& let Some(applied) = crate::clip::with_clip_effects_applied(first)
{
*first = Arc::new(applied);
}
for c in ch.iter_mut().skip(clip).filter(|c| c.visible) {
if !DrawnFrame::take_member() {
return;
+125 -3
View File
@@ -747,9 +747,9 @@ fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() {
assert!((w - 250.0).abs() < 1e-6, "{w}");
}
/// A clip group's clipping path keeps its own effects; the clipped members take theirs first.
/// A clip group's clipping path and the clipped members take their own effects first.
#[test]
fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() {
fn a_clipping_path_takes_its_own_effects_before_a_groups_effects() {
let mut clip = with_effect(square_at_100(), offset_10());
clip.id = NodeId(2);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
@@ -762,7 +762,9 @@ fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() {
let clip_group = art.children().unwrap()[0].clone();
let ch = clip_group.children().unwrap();
assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind);
assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path");
assert!(crate::reshape::geometry_effects(&ch[0]).is_empty(), "the clipping path's Offset Path is applied");
let w = ch[0].geometric_bounds().unwrap().width();
assert!((w - 60.0).abs() < 0.5, "grown by 10 on each side, then scaled to 50 %: {w}");
assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art");
}
@@ -1757,3 +1759,123 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked()
assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}");
}
}
/// Gathering the ruler guides of nested sublayers, and of a sublayer inside nested groups, looks
/// at each member at every depth once at most, and takes the same guides, in the same order and
/// state, as a layer's effects keep.
#[test]
fn gathering_sublayer_ruler_guides_looks_at_each_member_once() {
use std::cell::Cell;
use vectorcraft_doc::LayerColor;
const CHAIN: u64 = 6;
let guide = |id| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: true, pos: id as f64, artboard: None }));
let layer = |id, children: Vec<Arc<Node>>| {
let mut l = Node::layer(NodeId(id), "Sub", LayerColor::Preset(1));
if let Some(ch) = l.children_mut() {
*ch = children;
}
l
};
// Sublayers nested CHAIN deep, each holding the next before its own art and guide. The second
// is hidden and the fourth locked.
let first_guide = guide(201);
let mut chain = layer(100 + CHAIN, vec![sq(300 + CHAIN, 0.0, Color::BLACK), guide(200 + CHAIN)]);
for depth in (1..CHAIN).rev() {
let own = if depth == 1 { first_guide.clone() } else { guide(200 + depth) };
chain = layer(100 + depth, vec![Arc::new(chain), sq(300 + depth, 0.0, Color::BLACK), own]);
(chain.visible, chain.locked) = (depth != 2, depth == 4);
}
let second_guide = chain.children().and_then(|c| c[0].children()).map(|c| c[2].clone()).unwrap();
// A sublayer inside five nested groups, the third of them locked.
let mut groups = layer(450, vec![guide(250), sq(350, 0.0, Color::BLACK)]);
for depth in (1..=5).rev() {
groups = Node::group(NodeId(400 + depth), vec![Arc::new(groups)]);
groups.locked = depth == 3;
}
let free = Arc::new(Node::group(NodeId(500), vec![sq(501, 0.0, Color::BLACK), sq(502, 5.0, Color::BLACK)]));
// A sublayer with no guide: the art in its group is not looked at.
let quiet = Node::group(NodeId(601), vec![sq(602, 0.0, Color::BLACK), sq(603, 5.0, Color::BLACK)]);
let quiet = Arc::new(layer(600, vec![Arc::new(quiet)]));
let mut root = layer(10, vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(chain), Arc::new(groups), free.clone(), quiet.clone(), guide(20)]);
fn members(n: &Node) -> usize {
n.children().into_iter().flatten().map(|c| 1 + members(c)).sum()
}
let expected: Vec<_> = (1..=CHAIN).map(|depth| (NodeId(200 + depth), depth < 2, depth >= 4)).chain([(NodeId(250), true, true)]).collect();
let state = |g: &[Arc<Node>]| g.iter().map(|g| (g.id, g.visible, g.locked)).collect::<Vec<_>>();
let mut m = root.clone();
let looked = Cell::new(0_usize);
let is_guide = |n: &Node| {
looked.set(looked.get() + 1);
n.is_ruler_guide()
};
let mut taken = vec![];
take_sublayer_guides(&mut m, &mut taken, &is_guide);
assert_eq!(looked.get(), members(&root) - 2, "each member is looked at once, none in the quiet sublayer's group");
assert_eq!(state(&taken), expected, "a sublayer's own guides, then its sublayers', each once");
assert!(Arc::ptr_eq(&taken[0], &first_guide), "a guide with nothing to change is not copied");
assert!(!Arc::ptr_eq(&taken[1], &second_guide), "a guide made hidden is copied");
assert!(second_guide.visible, "the guide as it was stays as it was");
let ch = m.children().unwrap();
assert!(Arc::ptr_eq(&ch[3], &free), "a member with no sublayer guide is not copied");
assert!(Arc::ptr_eq(&ch[4], &quiet), "a sublayer with no guide is not copied");
assert!(ch[5].is_ruler_guide(), "the layer's own guide stays where it is");
let ids = |n: &Node| n.children().into_iter().flatten().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(ids(&ch[1]), [NodeId(102), NodeId(301)], "the sublayer's art stays, in order");
assert_eq!(ids(&root.children().unwrap()[1]), [NodeId(102), NodeId(301), NodeId(201)], "the source keeps its guides");
fn found(n: &Node, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.is_ruler_guide() {
out.push(c.id);
}
found(c, out);
}
}
for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] {
root.appearance.effects = vec![Effect { id: effect.into(), params, visible: true }];
let art = evaluate_container(None, &root).unwrap();
let ch = art.children().unwrap();
let tail = &ch[ch.len() - expected.len() - 1..];
assert_eq!(tail[0].id, NodeId(20), "{effect}: the layer's own guide comes first");
assert_eq!(state(&tail[1..]), expected, "{effect}: then the sublayers' guides");
let mut all = vec![];
found(&art, &mut all);
assert_eq!(all.len(), expected.len() + 1, "{effect}: each guide once");
}
}
/// A group's own Fill + Offset Path below its contents follows the outline of a clip group among
/// its members, its clipping path, not the clipped art it hides outside that path (#7289).
#[test]
fn a_groups_own_fill_follows_a_clip_groups_clipping_path_not_its_hidden_art() {
let mut clip = square_at_100();
clip.id = NodeId(2);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
// The clipped art reaches 100 pt past the clipping path on either side.
let hidden = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 100.0, 300.0, 200.0)), black());
let clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(hidden)], clip: true });
let mut g = Node::group(NodeId(10), vec![Arc::new(clipped.clone())]);
let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
fill.effects.push(offset_10());
g.appearance.items = vec![AppearanceItem::Fill(fill)];
g.appearance.set_contents_at(1);
let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y1 - b.y1).abs() < 0.5;
let expected = Rect::new(90.0, 90.0, 210.0, 210.0);
let art = evaluate_container(None, &g).unwrap();
let ch = art.children().unwrap();
assert_eq!(ch.len(), 2, "the fill's art below the clip group");
assert_eq!(ch[0].id, NodeId(10));
let b = drawn_bounds(&ch[0]).unwrap();
assert!(near(b, expected), "the offset fill hugs the clipping path: {b:?}");
// The clip group itself with that fill: the same outline.
let mut own = clipped;
own.appearance = g.appearance.clone();
assert_eq!(member_shapes(None, &own).len(), 1);
let b = member_shapes(None, &own)[0].0.bounds().unwrap();
assert!(near(b, Rect::new(100.0, 100.0, 200.0, 200.0)), "the clipping path's region: {b:?}");
}
+14 -4
View File
@@ -689,18 +689,28 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook,
}
}
/// Type `t` in a clip group's clipping path as its outlines ([`outline_text`]) with its
/// visibility, or with geometry effects as a path of the region they give it, which is what
/// [`crate::clip_outline`] clips by.
pub(crate) fn clipping_text_outline(t: &Node) -> Option<Node> {
if let Some(p) = crate::clip::with_clip_effects_applied(t) {
return Some(p);
}
let o = outline_text(t)?;
Some(Node { visible: t.visible, ..o })
}
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
/// its plain outlines ([`outline_text`]) with its visibility, which is what
/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting
/// their pieces name are looked up in `doc`.
/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and
/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`.
pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
let art = match &c.kind {
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
NodeKind::Text(_) if clipping => clipping_text_outline(c),
NodeKind::Text(_) => as_art(doc, c, None),
_ => None,
};
+69 -31
View File
@@ -8,6 +8,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, N
use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2};
use vectorcraft_three_d::{Edge, Revolve};
use crate::solids::wireframe::WireFace;
use crate::solids::{
Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds,
};
@@ -370,7 +371,9 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R
Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes)))
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
/// One object's surface of revolution about the axis of `b`, and each face it draws ([`Drawn`]),
/// back to front: by depth, and where two faces overlap, by their depths there
/// ([`crate::solids::surface_order`]). `copies` counts
/// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face
/// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the
/// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]):
@@ -388,7 +391,7 @@ fn surface(
blend: vectorcraft_doc::color::BlendMode,
expand: bool,
copies: &mut Copies,
) -> Option<(Vec<f64>, Node)> {
) -> Option<(Vec<Drawn>, Node)> {
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
if fx.id == CLASSIC_REVOLVE {
@@ -397,9 +400,12 @@ fn surface(
if copies.surfaces > MAX_PAINT_COPIES {
return None;
}
let depths = facets.iter().map(|f| f.depth).collect();
let order = crate::solids::surface_order(facets.iter().map(|f| (f.contours.first().map_or(&[][..], Vec::as_slice), f.plane, f.depth)));
let facets = in_order(facets, &order);
let drawn =
facets.iter().map(|f| Drawn { depth: f.depth, points: f.contours.first().cloned().unwrap_or_default(), plane: f.plane }).collect();
let p = merged_params(&fx.id, &fx.params);
return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p)));
return Some((drawn, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p)));
}
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
@@ -407,6 +413,8 @@ fn surface(
if copies.surfaces > MAX_PAINT_COPIES {
return None;
}
let order = crate::solids::surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth)));
let faces = in_order(faces, &order);
let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params));
let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored);
// Graphics past the limits are left out, and the surface draws without them and their paints'
@@ -437,21 +445,28 @@ fn surface(
&& n.has_default_transparency())
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
.flatten();
let faces = visible.unwrap_or_else(|| {
faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace {
path: f.path(),
brightness: f.brightness,
depth: f.depth,
gradient: f.gradient,
illumination: f.illumination,
light_gradient: f.light_gradient,
rgb_gradient: f.rgb_gradient,
})
.collect()
});
let depths: Vec<f64> = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect();
let (faces, keys): (Vec<vectorcraft_three_d::VisibleFace>, Vec<Drawn>) = match visible {
Some(visible) => {
let keys = visible.iter().map(|f| Drawn { depth: f.depth, points: vec![], plane: None }).collect();
(visible, keys)
}
None => {
let keys = faces.iter().map(|f| Drawn { depth: f.depth, points: f.points.clone(), plane: f.plane }).collect();
let faces = faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace {
path: f.path(),
brightness: f.brightness,
depth: f.depth,
gradient: f.gradient,
illumination: f.illumination,
light_gradient: f.light_gradient,
rgb_gradient: f.rgb_gradient,
})
.collect();
(faces, keys)
}
};
let children = faces
.into_iter()
.map(|face| {
@@ -517,7 +532,21 @@ fn surface(
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
Some((depths, group))
Some((keys, group))
}
/// A face [`surface`] draws: its depth and, for [`crate::solids::surface_order`], its polygon on the
/// drawing and its plane (none for a face trimmed to the part of it that shows).
struct Drawn {
depth: f64,
points: Vec<Point>,
plane: Option<vectorcraft_three_d::DepthPlane>,
}
/// `items` in `order`, a permutation of their indices.
fn in_order<T>(items: Vec<T>, order: &[usize]) -> Vec<T> {
let mut slots: Vec<Option<T>> = items.into_iter().map(Some).collect();
order.iter().filter_map(|&k| slots.get_mut(k).and_then(Option::take)).collect()
}
/// A surface of one object about the bounds of `path`, built with options derived from `fx`.
@@ -635,10 +664,16 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
/// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of
/// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for
/// each member, about one shared axis. The swatches and patterns named by a live-effect plug-in
/// after a fill's or stroke's Revolve are looked up in `doc`.
pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, String> {
let faces = |path: &PathData, b: Rect, options: Revolve| -> Result<Vec<PathData>, String> {
Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect())
/// after a fill's or stroke's Revolve are looked up in `doc`. Each face carries the width of the
/// stroke the canvas draws around it ([`surface`]).
pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<WireFace>, String> {
let faces = |path: &PathData, b: Rect, fx: &Effect, options: Revolve| -> Result<Vec<WireFace>, String> {
if fx.id == CLASSIC_REVOLVE {
let p = merged_params(&fx.id, &fx.params);
let facets = vectorcraft_three_d::revolve_facets(path, b, options)?;
return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect());
}
Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect())
};
if is_container(n) {
let index = n
@@ -654,7 +689,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec
let options = shared_options(&shapes, fx_options(fx))?;
let mut out = vec![];
for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) {
out.extend(faces(&s.path, b, options)?);
out.extend(faces(&s.path, b, fx, options)?);
}
return Ok(out);
}
@@ -667,7 +702,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec
return Err("the revolved object has no fill or stroke".into());
}
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
return faces(&path, b, fx_options(fx));
return faces(&path, b, fx, fx_options(fx));
}
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
@@ -677,7 +712,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec
{
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let surface = faces(&p, b, fx_options(fx))?;
let surface = faces(&p, b, fx, fx_options(fx))?;
out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx }));
}
}
@@ -934,14 +969,17 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
} else {
let count = surfaces.len();
let mut faces = vec![];
for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() {
for (member, (drawn, s, scopes)) in surfaces.into_iter().enumerate() {
let (opacity, blend) = (s.opacity, s.blend);
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face)));
faces.extend(drawn.into_iter().zip(children).map(|(drawn, face)| (drawn, member, opacity, blend, scopes, face)));
}
}
// A stable sort: faces at the same depth keep the members' order.
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
// A stable sort: faces at the same depth keep the members' order. Overlapping faces, of one
// member or of two, then go in the order of their depths where they overlap.
faces.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth));
let order = crate::solids::surface_order(faces.iter().map(|(d, ..)| (d.points.as_slice(), d.plane, d.depth)));
let faces = in_order(faces, &order);
if opaque {
vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))]
} else {
+67 -7
View File
@@ -223,6 +223,10 @@ pub(crate) fn solid_paint_sources(n: &Node) -> (Vec<Option<&AppearanceItem>>, Ve
}
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
#[cfg(test)]
if !is_plane(id) {
SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1));
}
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
@@ -292,7 +296,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at:
let mut shapes = members(n, label)?;
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?;
check_profiles(id, p, &shapes, b)?;
check_solids(id, p, &shapes)?;
return cast_shadow(id, p, &shapes).map(|_| ());
}
@@ -309,12 +313,24 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at:
None => (geometry(&path, before, &ctx), solid_paints(n)),
};
let b = path.bounds().ok_or_else(anchors)?;
facets(id, &path, rule, b, p)?;
let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }];
check_profiles(id, p, &shapes, b)?;
check_solids(id, p, &shapes)?;
cast_shadow(id, p, &shapes).map(|_| ())
}
/// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b`
/// build? Only the profiles no solid of [`check_solids`] is built from are built here: every
/// shape's for Rotate, and for Extrude & Bevel and Inflate the profile of a shape with no fill,
/// whose solids are its strokes' outlines or none. A filled shape's profile is its fill's solid,
/// which [`check_solids`] builds once, about the bounds the effect draws it about.
fn check_profiles(id: &str, p: &Value, shapes: &[Profile], b: Rect) -> Result<(), String> {
shapes
.iter()
.filter(|s| is_plane(id) || !s.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_))))
.try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))
}
/// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes`
/// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not
/// counted.
@@ -559,10 +575,8 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
.into_iter()
.map(|face| {
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
// paint over its neighbours.
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
// stroke of the same paint closes them ([`seam_width`]).
let seam = seam_width(&face);
if let Some(g) = face.rgb_gradient
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
{
@@ -592,6 +606,13 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
group
}
/// The width of the stroke of a facet's own paint that closes the seams between `face` and its
/// neighbors. No wider than the facet is thick: seen at a grazing angle a facet can be thinner than
/// the stroke, and its stroke would then paint over its neighbors.
pub(crate) fn seam_width(face: &Facet) -> f64 {
(face.thickness() / 2.0).clamp(0.05, 0.75)
}
/// Is a surface of `facets` in `paint` one composite of all its facets when [`surface`] draws it
/// whole: a solid color under colored lights with smooth shading?
fn composites_facets(facets: &[Facet], paint: &Paint, colored: bool) -> bool {
@@ -658,6 +679,9 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
thread_local! {
/// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests.
static SOLIDS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// How many Extrude & Bevel (Classic included) and Inflate solids [`facets`] and
/// [`built_solids`] have built on this thread, for the tests.
static SOLID_GEOMETRY_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from
@@ -687,7 +711,11 @@ fn built_solids<'a>(
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let built = match inflated.as_mut() {
Some(each) => each.next().unwrap_or_else(|| Ok(vec![])),
Some(each) => {
#[cfg(test)]
SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1));
each.next().unwrap_or_else(|| Ok(vec![]))
}
None => facets(id, area, *rule, bounds, p),
};
let result = match built {
@@ -1261,6 +1289,35 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec<usize> {
local_order(faces, order)
}
/// The order, back to front, of the faces of one or more Revolve surfaces `faces`, given back to
/// front by depth: each face's polygon on the drawing, its plane and its depth. When two
/// overlapping faces are drawn the wrong way round for their depths where they overlap, every pair
/// of overlapping faces with a convex polygon and a plane goes in the order of its depths there,
/// the face nearer the viewer after the other ([`local_order`]), and each step draws the face that
/// comes first in the given order of those whose faces before are drawn, so a face without a plane
/// or a convex polygon can also move. Otherwise the order given.
pub(crate) fn surface_order<'a>(faces: impl IntoIterator<Item = (&'a [Point], Option<DepthPlane>, f64)>) -> Vec<usize> {
let placed: Vec<Placed> = faces
.into_iter()
.map(|(points, plane, depth)| {
let local = plane.is_some() && convex(points).is_some();
// Every face counts as one of a stroke's on one shape, so each overlapping pair is compared.
Placed {
depth,
side: true,
away: false,
bounds: polygon_bounds(points),
member: 0,
paints: Paints::Other,
outline: if local { points.to_vec() } else { vec![] },
plane: plane.filter(|_| local),
}
})
.collect();
let order = (0..placed.len()).collect();
local_order(&placed, order)
}
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
/// its 3D effect, about the bounds of all of them.
pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
@@ -2178,3 +2235,6 @@ mod tests_sources;
#[cfg(test)]
mod tests_shadow;
#[cfg(test)]
mod tests_revolve_order;
+7 -1
View File
@@ -192,6 +192,12 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option<Color>) -> Color {
Color::rgb(r, g, b)
}
/// The width of the stroke [`surface`] draws around `face` with parameters `p`: a wire edge on
/// Wireframe, else the seam between faces.
pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 {
if classic_shading(p).surface == ClassicSurface::Wireframe { WIRE } else { super::seam_width(face) }
}
/// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child
/// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by
/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by
@@ -213,7 +219,7 @@ pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32
}
let (tone, spec) = shading.tone(face.normal, &lights);
// A narrow stroke of the face's own paint closes the hairline seams between faces.
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
let seam = super::seam_width(&face);
let items = match paint.color() {
// Unshaded (No Shading, no shading color, or a face in full light) with no
// highlight: the paint itself, its color space and swatch kept.
+42
View File
@@ -953,6 +953,48 @@ fn an_objects_solids_count_their_faces_together() {
}
}
/// How many Extrude & Bevel and Inflate solids `check` builds, and its result.
fn geometry_builds(check: impl FnOnce() -> Result<(), String>) -> (usize, Result<(), String>) {
SOLID_GEOMETRY_BUILT.with(|b| b.set(0));
let result = check();
(SOLID_GEOMETRY_BUILT.with(std::cell::Cell::get), result)
}
/// The check builds each solid the effect draws once, about the bounds it draws it about: one for
/// a fill and one for each stroke, on an object, on a group's members and on an appearance item,
/// with the shadow on or off.
#[test]
fn the_check_builds_each_drawn_solid_once() {
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
let n = square_node();
assert_eq!(geometry_builds(|| validate_solid(&n, id, &json!({}))), (2, Ok(())), "{id}: a fill and a stroke");
let mut other = square_node();
other.id = NodeId(2);
let g = Node::group(NodeId(9999), vec![Arc::new(square_node()), Arc::new(other)]);
assert_eq!(geometry_builds(|| validate_solid(&g, id, &json!({}))), (4, Ok(())), "{id}: two members' fills and strokes");
}
for id in [EXTRUDE, INFLATE] {
let n = square_node();
assert_eq!(geometry_builds(|| validate_three_d_at(&n, id, &json!({}), (Some(0), 0))), (1, Ok(())), "{id}: on the fill");
let shadow = json!({"shadow": true});
assert_eq!(geometry_builds(|| validate_solid(&n, id, &shadow)), (2, Ok(())), "{id}: with the shadow on");
}
}
/// A shape with no fill draws no solid of its profile, so the check builds the profile on its own
/// as well as the strokes' solids, and an unpainted shape's profile alone.
#[test]
fn the_check_builds_the_profile_of_a_shape_with_no_fill() {
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
let mut stroked = square_node();
stroked.appearance.items.retain(|i| matches!(i, AppearanceItem::Stroke(_)));
assert_eq!(geometry_builds(|| validate_solid(&stroked, id, &json!({}))), (2, Ok(())), "{id}: a stroke alone");
let mut bare = square_node();
bare.appearance.items.clear();
assert_eq!(geometry_builds(|| validate_solid(&bare, id, &json!({}))), (1, Ok(())), "{id}: no paint");
}
}
/// A convex polygon of `corners` corners about (20,000, 20,000), filled red, with no stroke.
fn polygon_node(id: u64, corners: usize) -> Node {
let points: Vec<Point> = (0..corners)
@@ -0,0 +1,183 @@
use serde_json::json;
use super::*;
use crate::revolve::{CLASSIC_REVOLVE, REVOLVE, revolve_art, revolve_options};
/// The first pair of faces `(polygon, plane)`, in the order given, drawn the wrong way round where
/// their polygons overlap: the earlier one nearer the viewer at the middle of the overlap.
fn misordered(faces: &[(Vec<Point>, Option<DepthPlane>)]) -> Option<(usize, usize)> {
for (i, (a, pa)) in faces.iter().enumerate() {
let Some(pa) = pa.filter(|_| convex(a).is_some()) else { continue };
for (j, (b, pb)) in faces.iter().enumerate().skip(i + 1) {
let Some(pb) = pb else { continue };
let Some(turn) = convex(b) else { continue };
if !rects_overlap(polygon_bounds(a), polygon_bounds(b)) {
continue;
}
let Some(mid) = centroid(&clip_convex(a, b, turn)) else { continue };
let (Some(da), Some(db)) = (pa.depth_at(mid), pb.depth_at(mid)) else { continue };
if da - db > 1e-6 * (1.0 + da.abs().max(db.abs())) {
return Some((i, j));
}
}
}
None
}
/// An open, vase-like profile of straight edges.
fn vase() -> PathData {
let mut b = BezPath::new();
b.move_to((30.0, 20.0));
for p in [(90.0, 20.0), (55.0, 65.0), (95.0, 110.0), (30.0, 145.0)] {
b.line_to(p);
}
PathData::from_bezpath(&b)
}
/// The `k`th of five closed 14 × 20 pt rectangles stepping down and away from the axis, as the
/// dashes of a dashed line's outline do.
fn dash(k: usize) -> PathData {
let (x, y) = (30.0 + 12.0 * k as f64, 20.0 + 26.0 * k as f64);
let mut b = BezPath::new();
b.move_to((x, y));
b.line_to((x + 14.0, y));
b.line_to((x + 14.0, y + 20.0));
b.line_to((x, y + 20.0));
b.close_path();
PathData::from_bezpath(&b)
}
/// The five [`dash`]es as one compound path.
fn dashes() -> PathData {
PathData::new((0..5).flat_map(|k| dash(k).subpaths).collect())
}
/// A tilted three-quarter turn of [`vase`], whose faces sorted by their middles' depths draw a
/// nearer face under a farther one where the two overlap.
fn params() -> Value {
json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 24, "cap": true})
}
/// A three-quarter turn of [`dashes`], steeply tilted and in wide perspective, whose classic facets
/// sorted by their middles' depths draw a nearer facet under a farther one where the two overlap.
fn classic_params() -> Value {
json!({"angle": 270, "offset": 12, "rotationX": -60, "rotationY": 0, "perspectiveAngle": 80, "cap": true})
}
fn node(path: PathData) -> Node {
Node::path(NodeId(1), path, Appearance::basic(Paint::solid(Color::rgb(0.1, 0.7, 0.8)), Paint::None, 0.0))
}
/// The faces of `faces` (polygon, plane and outline) in the order `art` draws their outlines.
fn drawn_order(art: &Node, faces: &[(Vec<Point>, Option<DepthPlane>, PathData)]) -> Vec<(Vec<Point>, Option<DepthPlane>)> {
let mut out = vec![];
art.walk(&mut |n| {
if let Some(path) = n.path_data()
&& let Some((points, plane, _)) = faces.iter().find(|(.., outline)| outline == path)
{
out.push((points.clone(), *plane));
}
});
out
}
/// Of a tilted, revolved vase, a face sorted by the depth of its middle goes under a farther face
/// that it is in front of where the two overlap. The surface draws the nearer face last there, on
/// its own and as a member of a revolved group.
#[test]
fn a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last() {
let path = vase();
let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&params())).unwrap();
let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect();
assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere");
let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect();
let mut n = node(path.clone());
n.appearance.effects.push(crate::new_effect(REVOLVE, &params()).unwrap());
let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed);
assert_eq!(drawn.len(), faces.len());
assert_eq!(misordered(&drawn), None, "an object");
let mut g = Node::group(NodeId(9), vec![Arc::new(node(path))]);
g.appearance.effects.push(crate::new_effect(REVOLVE, &params()).unwrap());
let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed);
assert_eq!(drawn.len(), faces.len());
assert_eq!(misordered(&drawn), None, "a group's member");
}
/// The faces of a group's members, sorted together by the depths of their middles, go in the order
/// of their depths where they overlap, those of two members as well as those of one.
#[test]
fn a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last() {
let p = json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 16, "cap": true});
let members: Vec<PathData> = (0..5).map(dash).collect();
let b = dashes().bounds().unwrap();
let mut faces: Vec<_> = members.iter().flat_map(|m| vectorcraft_three_d::revolve(m, b, revolve_options(&p)).unwrap()).collect();
faces.sort_by(|a, b| a.depth.total_cmp(&b.depth));
let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect();
assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere");
let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect();
let mut g = Node::group(NodeId(9), members.into_iter().map(|m| Arc::new(node(m))).collect());
g.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap());
let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed);
assert_eq!(drawn.len(), faces.len());
assert_eq!(misordered(&drawn), None);
}
/// Revolve (Classic) draws the nearer of two overlapping facets last where they overlap, too.
#[test]
fn a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last() {
let path = dashes();
// Revolve (Classic) sweeps its surface unshaded, with one facet for each cell.
let classic = vectorcraft_three_d::Revolve {
shade: false,
smooth_shading: false,
lights: None,
light_color: [1.0; 3],
..revolve_options(&crate::merged_params(CLASSIC_REVOLVE, &classic_params()))
};
let facets = vectorcraft_three_d::revolve_facets(&path, path.bounds().unwrap(), classic).unwrap();
let by_depth: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane)).collect();
assert!(misordered(&by_depth).is_some(), "the facets by depth are drawn the wrong way round somewhere");
let keyed: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane, f.path())).collect();
let mut n = node(path);
n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &classic_params()).unwrap());
let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed);
assert_eq!(drawn.len(), facets.len());
assert_eq!(misordered(&drawn), None);
}
/// A surface whose faces by depth are already drawn the right way round wherever they overlap keeps
/// that order.
#[test]
fn a_surface_already_in_order_keeps_it() {
let path = vase();
let p = json!({"angle": 360, "offset": 12, "rotationX": 0, "rotationY": 0, "segments": 24, "cap": true});
let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&p)).unwrap();
let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect();
assert_eq!(misordered(&by_depth), None);
let order = surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth)));
assert_eq!(order, (0..faces.len()).collect::<Vec<_>>());
}
/// Of two overlapping faces, the one nearer the viewer where they overlap goes after the other,
/// whatever their depths; a face without a plane, and one that overlaps neither, keep their places.
/// Two faces at the same depth there keep their order.
#[test]
fn surface_order_puts_the_nearer_face_where_they_overlap_last() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)];
// z = 10 - x: at the middle of its overlap with the next square (x 7.5) it is at z 2.5.
let r = std::f64::consts::FRAC_1_SQRT_2;
let sloped = Some(DepthPlane { normal: [r, 0.0, r], offset: 10.0 * r, center: Point::ZERO, eye: None });
let flat = |z: f64| Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None });
let (a, b, apart) = (square(0.0), square(5.0), square(40.0));
let faces = [(&a[..], sloped, 5.0), (&apart[..], flat(0.0), 1.0), (&b[..], flat(3.0), 3.0)];
assert_eq!(surface_order(faces), vec![0, 1, 2], "the flat face, nearer at the overlap, is already last");
let faces = [(&b[..], flat(2.0), 2.0), (&apart[..], flat(0.0), 3.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![0, 1, 2], "the sloped face, nearer at the overlap, is already last");
// The sloped face's middle (x 5) is at z 5, but the flat face at z 3 is nearer at the overlap.
let faces = [(&b[..], flat(3.0), 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![1, 2, 0], "the flat face is nearer at the overlap");
let faces = [(&b[..], None, 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![0, 1, 2], "no plane");
let faces = [(&b[..], flat(2.5), 3.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![0, 1], "the same depth there");
}
+36 -16
View File
@@ -4,13 +4,13 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId};
use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath};
use vectorcraft_three_d::Facet;
use super::{
Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region,
reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds,
CLASSIC_EXTRUDE, CLASSIC_ROTATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of,
members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds,
};
use crate::{GeomContext, is_geometry};
@@ -54,7 +54,7 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<Path
// cuts it ([`crate::revolve_art_in`]).
let n = &*if revolve { resolve_for(doc, n, label, false)? } else { resolve(doc, n, label)? };
let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? };
let faces = after_geometry(doc, n, faces, revolve);
let faces: Vec<PathData> = after_geometry(doc, n, faces, revolve).into_iter().map(|f| f.path).collect();
let wire = edges(&faces)?;
if wire.is_empty() {
return Err(format!("{label} draws no faces for this object"));
@@ -65,29 +65,46 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<Path
/// `faces` reshaped by the visible geometry effects after the 3D effect of `n`'s own that drew them
/// ([`reshaped`]): its first visible Revolve when `revolve` is set, else its first visible Extrude &
/// Bevel, Inflate or Rotate.
fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec<PathData>, revolve: bool) -> Vec<PathData> {
fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec<WireFace>, revolve: bool) -> Vec<WireFace> {
let fx = &n.appearance.effects;
let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) };
let Some(index) = drawn else { return faces };
reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) })
}
/// One face of a drawn 3D surface: its outline and the width of the stroke the canvas draws around
/// it (the seam between faces, or a wire edge), which a later Outline Stroke outlines.
pub(crate) struct WireFace {
pub(crate) path: PathData,
pub(crate) stroke: f64,
}
/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as
/// they reshape the faces of the drawn surface ([`crate::reshape::reshape_leaves`]): each effect
/// reshapes each face on its own, about the bounds all the faces have before it, and the faces keep
/// to one budget of anchors together. `ctx` gives the object's id, the seed of a random Transform,
/// and the document.
pub(crate) fn reshaped(faces: Vec<PathData>, later: &[Effect], ctx: &GeomContext) -> Vec<PathData> {
/// to one budget of anchors together. Each face is filled and carries its round-joined stroke, as on
/// the canvas, so an Outline Stroke outlines that stroke. `ctx` gives the object's id, the seed of a
/// random Transform, and the document.
pub(crate) fn reshaped(faces: Vec<WireFace>, later: &[Effect], ctx: &GeomContext) -> Vec<WireFace> {
let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect();
if after.is_empty() {
return faces;
}
let id = NodeId(ctx.seed);
let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect());
let paint = Paint::solid(Color::BLACK);
let face = |f: WireFace| {
let items =
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), f.stroke))];
Arc::new(Node::path(id, f.path, Appearance { items, ..Default::default() }))
};
let mut art = Node::group(id, faces.into_iter().map(face).collect());
crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc);
let mut out = vec![];
art.walk(&mut |c| out.extend(c.path_data().cloned()));
art.walk(&mut |c| {
if let Some(path) = c.path_data() {
out.push(WireFace { path: path.clone(), stroke: c.appearance.stroke().map_or(0.0, |s| s.width) });
}
});
out
}
@@ -95,7 +112,7 @@ pub(crate) fn reshaped(faces: Vec<PathData>, later: &[Effect], ctx: &GeomContext
/// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer,
/// the solids of its members, about one shared axis. The swatches and patterns named by a live-effect
/// plug-in after a fill's or stroke's 3D effect are looked up in `doc`.
fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, String> {
fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<WireFace>, String> {
if is_container(n) {
let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?;
let mut shapes = members(n, label_of(&fx.id))?;
@@ -126,17 +143,20 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, S
/// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's
/// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out,
/// as it is left out of the drawn art; past the limits of the object's solids together, the result
/// is an error, as the canvas draws the source art.
fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<PathData>, String> {
/// is an error, as the canvas draws the source art. Each facet carries the width of the stroke the
/// canvas draws around it.
fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<WireFace>, String> {
let label = label_of(&fx.id);
let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
let p = crate::merged_params(&fx.id, &fx.params);
let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE;
let face = |f: &Facet| WireFace { path: f.path(), stroke: if classic { super::classic::stroke_width(f, &p) } else { super::seam_width(f) } };
let mut out = vec![];
if is_plane(&fx.id) {
for s in shapes {
for item in &s.items {
let (area, rule) = region(&s.path, s.rule, item);
out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(Facet::path));
out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(face));
}
}
return Ok(out);
@@ -144,7 +164,7 @@ fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<PathData>, String>
let (parts, bounds) = solid_parts(shapes, b);
// The solids the art draws, each past the limits of the object's solids together as it is.
for f in built_solids(&fx.id, &p, &parts, bounds, vectorcraft_three_d::MAX_OBJECT_FACES) {
out.extend(f?.iter().flatten().map(Facet::path));
out.extend(f?.iter().flatten().map(face));
}
Ok(out)
}
+33 -3
View File
@@ -163,13 +163,43 @@ fn revolve_classic_and_rotate_classic_expand_too() {
fn later_geometry_effects_reshape_each_face_on_its_own() {
let n = solid_square(super::super::EXTRUDE, json!({}));
let fx = [crate::new_effect("path.offsetPath", &json!({"offset": 1.0})).unwrap()];
let out = reshaped(vec![square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], &fx, &GeomContext::of(&n));
assert_eq!(out.len(), 2, "{out:?}");
let (a, b) = (out[0].bounds().unwrap(), out[1].bounds().unwrap());
let faces = [square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)].map(|path| WireFace { path, stroke: 0.75 });
let out = reshaped(faces.into(), &fx, &GeomContext::of(&n));
assert_eq!(out.len(), 2);
let (a, b) = (out[0].path.bounds().unwrap(), out[1].path.bounds().unwrap());
assert!((a.x0 + 1.0).abs() < 1e-3 && (a.x1 - 11.0).abs() < 1e-3, "{a:?}");
assert!((b.x0 - 9.0).abs() < 1e-3 && (b.x1 - 21.0).abs() < 1e-3, "{b:?}");
}
/// An Outline Stroke after a 3D effect outlines the stroke each face is drawn with (the seam between
/// faces, or a Classic wire edge), not a plain 1 pt stroke: the wireframe covers the outlined faces
/// the canvas draws, in the same place.
#[test]
fn a_later_outline_stroke_outlines_the_stroke_each_face_is_drawn_with() {
let outline = || crate::new_effect("path.outlineStroke", &json!({})).unwrap();
let cases = [
(super::super::EXTRUDE, json!({})),
(super::super::CLASSIC_EXTRUDE, json!({})),
(super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"})),
(super::super::INFLATE, json!({})),
(ROTATE, json!({})),
(crate::REVOLVE, json!({})),
(crate::CLASSIC_REVOLVE, json!({})),
(crate::CLASSIC_REVOLVE, json!({"surface": "wireframe"})),
];
for (id, params) in cases {
let mut n = solid_square(id, params.clone());
n.appearance.effects.push(outline());
let wire = three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
let art = if crate::revolve::is_revolve(id) { crate::revolve_art(&n) } else { super::super::solid_art(&n) }.unwrap();
let drawn = crate::group::shape_art(None, &art, None).unwrap();
let (w, a) = (bounds(&wire), bounds(&face_paths(&drawn)));
for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] {
assert!((x - y).abs() < 1e-6, "{id} {params}: {w:?} {a:?}");
}
}
}
/// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface.
#[test]
fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() {
+1
View File
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
mod clipfx;
mod imageoutline;
mod painted;
mod rasterfollow;
+401
View File
@@ -0,0 +1,401 @@
//! Live effects on the clipping path of a clip group: the region it clips to takes the shape its
//! geometry effects give it, on the canvas as in an export.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Rect, Shape, shapes};
use crate::{bake_document, clip_outline};
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// A 100 pt square clipping path at (50, 50) with `effects`.
fn clipping_path(id: u64, effects: Vec<Effect>) -> Node {
let mut p = Node::path(NodeId(id), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut p.kind {
*clipping = true;
}
p.appearance.effects = effects;
p
}
/// A clip group: `clip` clipping a red 200 pt square at the origin.
fn clip_group(id: u64, clip: Node) -> Node {
let art = Node::path(
NodeId(id + 100),
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
);
Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true })
}
fn bounds(n: &Node) -> Rect {
clip_outline(n).map(|(bp, _)| bp.bounding_box()).unwrap()
}
/// The clipping path of the first clip group in `doc` after an export bakes it.
fn baked_clip(doc: &Document) -> Arc<Node> {
fn first(n: &Arc<Node>) -> Option<Arc<Node>> {
match &n.kind {
NodeKind::Group { children, clip: true } => children.first().cloned(),
_ => n.children()?.iter().find_map(first),
}
}
let baked = bake_document(doc).unwrap();
baked.layers.iter().find_map(first).unwrap()
}
#[test]
fn transform_moves_the_clip() {
let clip = clipping_path(1, vec![fx("distort.transform", json!({"moveH": 60}))]);
let b = bounds(&clip);
assert!((b.x0 - 110.0).abs() < 1e-6 && (b.x1 - 210.0).abs() < 1e-6, "the clip moves with the clipping path: {b:?}");
assert!((b.y0 - 50.0).abs() < 1e-6 && (b.y1 - 150.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn roughen_clips_as_an_export_does() {
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
let group = clip_group(1, clipping_path(2, vec![fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))]));
d.insert(Some(layer), 0, group.clone()).unwrap();
let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") };
let live = clip_outline(&children[0]).unwrap();
let exported = clip_outline(&baked_clip(&d)).unwrap();
assert_eq!(live.1, exported.1);
assert_eq!(live.0, exported.0, "the canvas clips by the outline the export writes");
assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0), "the roughened outline, not the plain square");
}
#[test]
fn nested_members_keep_their_effects() {
// A group as the clipping path: its member's Transform effect moves its part of the region.
let moved = clipping_path(2, vec![fx("distort.transform", json!({"moveV": 60}))]);
let group = Node::group(NodeId(1), vec![Arc::new(moved)]);
let b = bounds(&group);
assert!((b.y0 - 110.0).abs() < 1e-6 && (b.y1 - 210.0).abs() < 1e-6, "{b:?}");
// A clip group as the clipping path clips by its own clipping path, effects and all.
let nested = clip_group(3, clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]));
let b = bounds(&nested);
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}");
// So does the nested clip group's own effect.
let mut nested = clip_group(7, clipping_path(8, vec![]));
nested.appearance.effects.push(fx("distort.transform", json!({"moveH": 30})));
let b = bounds(&nested);
assert!((b.x0 - 80.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6, "{b:?}");
// The group's own Transform effect applies on top of its members'.
let mut group = Node::group(NodeId(5), vec![Arc::new(clipping_path(6, vec![fx("distort.transform", json!({"moveV": 60}))]))]);
group.appearance.effects.push(fx("distort.transform", json!({"moveH": 10})));
let b = bounds(&group);
assert!((b.x0 - 60.0).abs() < 1e-6 && (b.y0 - 110.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn a_roughened_group_clips_as_an_export_does() {
// Two members side by side, Roughen on the group: each member is roughened, as in the export.
let member = |id: u64, x: f64| {
Arc::new(Node::path(
NodeId(id),
shapes::rectangle(Rect::new(x, 50.0, x + 40.0, 150.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
))
};
let mut clip = Node::group(NodeId(2), vec![member(3, 50.0), member(4, 110.0)]);
clip.appearance.effects.push(fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false})));
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(1, clip.clone())).unwrap();
let live = clip_outline(&clip).unwrap();
assert_eq!(live.0, clip_outline(&baked_clip(&d)).unwrap().0, "the canvas clips by the outline the export writes");
assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0));
}
#[test]
fn a_compound_paths_member_keeps_its_effect() {
// A ring whose hole a Transform effect moves 20 pt right, as the export bakes it.
let ring = |effects: Vec<Effect>| {
let outer = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default());
let mut hole = Node::path(NodeId(4), shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)), Appearance::default());
hole.appearance.effects = effects;
Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(hole)], rule: vectorcraft_geom::FillRule::EvenOdd })
};
let moved = ring(vec![fx("distort.transform", json!({"moveH": 20}))]);
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(1, moved.clone())).unwrap();
let live = clip_outline(&moved).unwrap();
assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes");
assert_ne!(live, clip_outline(&ring(vec![])).unwrap(), "the hole moves");
// With a Transform of the compound path's own moving the whole ring 10 pt down after it.
let mut both = moved.clone();
both.appearance.effects.push(fx("distort.transform", json!({"moveV": 10})));
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(1, both.clone())).unwrap();
let live = clip_outline(&both).unwrap();
assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes");
assert_ne!(live.0.bounding_box(), clip_outline(&moved).unwrap().0.bounding_box(), "the ring moves down");
}
#[test]
fn the_clipping_paths_effects_apply_before_the_clip_groups() {
// Scale 50 % on the clipping path, then Offset Path 10 on its clip group: the region is the
// half-size square grown by 10, as if the clipping path had been drawn at half size.
let group = |clip: Node| {
let mut g = clip_group(1, clip);
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10})));
g
};
let scaled = group(clipping_path(2, vec![fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))]));
let mut half = clipping_path(2, vec![]);
if let NodeKind::Path { path, .. } = &mut half.kind {
*path = shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0));
}
let want = bounds(&group(half));
let got = bounds(&scaled);
assert!((got.x0 - want.x0).abs() < 0.5 && (got.x1 - want.x1).abs() < 0.5, "{got:?} against {want:?}");
assert!((want.x0 - 65.0).abs() < 0.5, "{want:?}");
// The clip group as the canvas evaluates it for its effect, and as an export bakes it.
let evaluated = crate::evaluate_container(None, &scaled).unwrap();
let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") };
let b = clip_outline(&children[0]).unwrap().0.bounding_box();
assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "evaluated: {b:?} against {want:?}");
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, scaled).unwrap();
let b = clip_outline(&baked_clip(&d)).unwrap().0.bounding_box();
assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "exported: {b:?} against {want:?}");
}
#[test]
fn nested_effects_apply_before_the_clip_groups() {
// The clip group grows its clipping path by 10 (Offset Path). Inside the clipping path a
// Transform moves a member 60 pt right: the region grows from the moved member.
let evaluated = |clip: Node| {
let mut g = clip_group(1, clip);
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10})));
let e = crate::evaluate_container(None, &g).unwrap();
let NodeKind::Group { children, clip: true } = &e.kind else { panic!("not a clip group") };
clip_outline(&children[0]).unwrap().0.bounding_box()
};
let moved = || clipping_path(3, vec![fx("distort.transform", json!({"moveH": 60}))]);
let close = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5;
let want = Rect::new(100.0, 40.0, 220.0, 160.0);
// A group holding the member.
let b = evaluated(Node::group(NodeId(2), vec![Arc::new(moved())]));
assert!(close(b, want), "group: {b:?}");
// A compound path of the member, with a Transform of its own moving it 10 pt down.
let mut compound = Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(moved())], rule: vectorcraft_geom::FillRule::NonZero });
compound.appearance.effects.push(fx("distort.transform", json!({"moveV": 10})));
let b = evaluated(compound);
assert!(close(b, Rect::new(100.0, 50.0, 220.0, 170.0)), "compound: {b:?}");
}
#[test]
fn clipping_type_keeps_its_effect_where_other_type_stays_type() {
// Type moved 30 pt right by its Transform clips a red square, beside other type, in a group
// that a Transform moves 10 pt down: the export keeps the other type as type and outlines the
// clipping type, which still clips where the canvas clips.
use vectorcraft_doc::text::{CharStyle, TextObject};
let text = |id: u64, effects: Vec<Effect>| {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut t = Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style))));
t.appearance.effects = effects;
t
};
let clipped = clip_group(2, text(3, vec![fx("distort.transform", json!({"moveH": 30}))]));
let mut outer = Node::group(NodeId(1), vec![Arc::new(clipped), Arc::new(text(5, vec![]))]);
outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 10})));
fn clip_of(n: &Node) -> Option<Rect> {
match &n.kind {
NodeKind::Group { children, clip: true } => Some(clip_outline(children.first()?)?.0.bounding_box()),
_ => n.children()?.iter().find_map(|c| clip_of(c)),
}
}
if vectorcraft_text::CRAFT_FONTS.is_empty() {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let plain = clip_outline(&text(3, vec![])).unwrap().0.bounding_box();
let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()).unwrap();
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, outer).unwrap();
let baked = bake_document(&d).unwrap();
let mut exported = None;
for l in &baked.layers {
exported = exported.or_else(|| clip_of(l));
}
let exported = exported.unwrap();
assert!((canvas.x0 - plain.x0 - 30.0).abs() < 0.5 && (canvas.y0 - plain.y0 - 10.0).abs() < 0.5, "{canvas:?} from {plain:?}");
assert!((exported.x0 - canvas.x0).abs() < 0.5 && (exported.y0 - canvas.y0).abs() < 0.5, "exported {exported:?}, canvas {canvas:?}");
// The outlined clipping type keeps its black fill, which paints behind the clipped art.
let outlined = crate::reshape::clipping_text_outline(&text(3, vec![fx("distort.transform", json!({"moveH": 30}))])).unwrap();
fn fills(n: &Node) -> usize {
n.appearance.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(f) if f.paint != Paint::None)).count()
+ n.children().into_iter().flatten().map(|c| fills(c)).sum::<usize>()
}
assert!(fills(&outlined) > 0, "{outlined:?}");
}
#[test]
fn a_painted_clipping_paths_gradient_follows_its_effect() {
// A clipping path filled with a gradient placed across it, moved 60 pt right by its Transform
// effect, in a clip group with an effect of its own: the gradient moves with the path.
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)];
let geom = GradientGeom { start: (50.0, 100.0).into(), end: (150.0, 100.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
let mut clip = clipping_path(2, vec![fx("distort.transform", json!({"moveH": 60}))]);
clip.appearance = Appearance { effects: clip.appearance.effects.clone(), ..Appearance::basic(placed, Paint::None, 0.0) }.into();
let mut group = clip_group(1, clip);
group.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
let evaluated = crate::evaluate_container(None, &group).unwrap();
let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") };
let Paint::Gradient(g) = children[0].appearance.fill_paint() else { panic!("no gradient") };
let start = g.geom.unwrap().start;
assert!((start.x - 110.0).abs() < 1e-6, "the gradient starts at the moved path's left edge: {start:?}");
}
#[test]
fn a_nested_clip_groups_hidden_clipping_path_keeps_its_effect() {
// A clip group used as a clipping path clips by its clipping path whether it is shown or not.
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]);
hidden.visible = false;
let outer = clip_group(1, clip_group(3, hidden));
d.insert(Some(layer), 0, outer.clone()).unwrap();
let NodeKind::Group { children, .. } = &outer.kind else { panic!("not a group") };
let b = bounds(&children[0]);
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}");
assert_eq!(clip_outline(&children[0]), clip_outline(&baked_clip(&d)), "the canvas clips by the outline the export writes");
}
#[test]
fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() {
// The clipping path's fill has a Transform effect moving it 60 pt right; the path has none.
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
let mut clip = clipping_path(2, vec![]);
clip.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into();
for item in &mut clip.appearance.items {
if let vectorcraft_doc::AppearanceItem::Fill(f) = item {
f.effects.push(fx("distort.transform", json!({"moveH": 60})));
}
}
let group = clip_group(1, clip);
d.insert(Some(layer), 0, group.clone()).unwrap();
let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") };
let live = clip_outline(&children[0]).unwrap();
let exported = clip_outline(&baked_clip(&d)).unwrap();
assert_eq!(live.0.bounding_box(), exported.0.bounding_box(), "the canvas clips by the outline the export writes");
assert!((live.0.bounding_box().x1 - 210.0).abs() < 1e-6, "the moved fill reaches 210: {:?}", live.0.bounding_box());
// A path of a compound clipping path with a fill effect of its own stays part of the region.
let mut member = clipping_path(4, vec![]);
member.appearance = children[0].appearance.clone();
let other = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default());
let compound =
Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(member), Arc::new(other)], rule: vectorcraft_geom::FillRule::NonZero });
assert_eq!(bounds(&compound), Rect::new(0.0, 0.0, 150.0, 150.0));
}
#[test]
fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() {
// Offset Path 10 on the fill of an unpainted clipping path, in a group that its Transform
// scales to 50 %: the region is the grown square at half size, 60 pt across, as it is when the
// clipping path is the member of a group that clips by its members.
let clip_with = |fill: Paint| {
let mut clip = clipping_path(3, vec![]);
clip.appearance = Appearance::basic(fill, Paint::None, 0.0).into();
for item in &mut clip.appearance.items {
if let vectorcraft_doc::AppearanceItem::Fill(f) = item {
f.effects.push(fx("path.offsetPath", json!({"offset": 10})));
}
}
clip
};
let scaled = |clip: Node| {
let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip))]);
outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50})));
outer
};
fn first_clip(n: &Node) -> Option<Arc<Node>> {
match &n.kind {
NodeKind::Group { children, clip: true } => children.first().cloned(),
_ => n.children()?.iter().find_map(|c| first_clip(c)),
}
}
let clip_of = |n: &Node| clip_outline(&first_clip(n).unwrap()).unwrap().0.bounding_box();
let close = |r: Rect| (r.width() - 60.0).abs() < 0.5 && (r.height() - 60.0).abs() < 0.5;
let nested = clip_of(&crate::evaluate_container(None, &scaled(Node::group(NodeId(4), vec![Arc::new(clip_with(Paint::None))]))).unwrap());
assert!(close(nested), "nested: {nested:?}");
let outer = scaled(clip_with(Paint::None));
let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap());
assert!(close(canvas), "canvas: {canvas:?}");
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, outer).unwrap();
let baked = bake_document(&d).unwrap();
let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap();
assert!(close(exported), "exported: {exported:?}");
// A clipping path that paints its fill keeps it, a path with the fill's effect live.
let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap();
assert!(painted.clip_paint().fill.is_some(), "{painted:?}");
}
#[test]
fn a_baked_clipping_path_gives_its_pieces_ids_of_their_own() {
// An unpainted clipping path whose fill and stroke have Transform effects, in a clip group
// without effects: the export bakes it into a piece for each, every object with its own id.
let mut d = Document::new(400.0, 400.0);
let (group, path) = (d.alloc_id(), d.alloc_id());
let mut clip = clipping_path(path.0, vec![]);
clip.appearance = Appearance::basic(Paint::None, Paint::None, 1.0).into();
for item in &mut clip.appearance.items {
match item {
vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("distort.transform", json!({"moveH": 60}))),
vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveV": 60}))),
}
}
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(group.0, clip)).unwrap();
let baked = bake_document(&d).unwrap();
let clip = baked_clip(&d);
assert_eq!(clip.children().map(|c| c.len()), Some(2), "{clip:?}");
fn ids(n: &Node, out: &mut Vec<NodeId>) {
out.push(n.id);
for c in n.children().into_iter().flatten() {
ids(c, out);
}
}
let mut all = vec![];
for l in &baked.layers {
ids(l, &mut all);
}
let unique: std::collections::HashSet<_> = all.iter().collect();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
#[test]
fn a_hidden_member_with_an_effect_adds_nothing() {
let shown = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), Appearance::default());
let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": 200}))]);
hidden.visible = false;
let group = Node::group(NodeId(2), vec![Arc::new(shown), Arc::new(hidden)]);
assert_eq!(bounds(&group), Rect::new(50.0, 50.0, 100.0, 100.0));
}
#[test]
fn raster_and_hidden_effects_leave_the_clip_as_it_is() {
let mut hidden = fx("distort.transform", json!({"moveH": 60}));
hidden.visible = false;
for effects in [vec![], vec![fx("blur.gaussian", json!({"radius": 10}))], vec![hidden]] {
assert_eq!(bounds(&clipping_path(1, effects)), Rect::new(50.0, 50.0, 150.0, 150.0));
}
}
+5 -3
View File
@@ -21,6 +21,7 @@ use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_render::effects;
use super::super::appearance::series_children_in_paint_order;
use super::super::colorcmds::edit_if_changed;
use super::super::edit::selected_roots;
use super::super::graph::capture_series_object_opacity;
use super::super::menucmds::whole_or;
@@ -92,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Randomize Opacity",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}",
"{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step (none when no opacity changes); it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}",
has_selection,
randomize_opacity
),
@@ -397,7 +398,8 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({ "count": 0 }));
}
let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32);
s.edit("Randomize Opacity", |d, _| {
// A run that gives every object the opacity it has records no undo step.
edit_if_changed(s, "Randomize Opacity", |_, d| {
let mut changed = vec![];
for id in &ids {
let o = rng.range(lo, hi);
@@ -409,7 +411,7 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result<Value> {
}
}
keep_series_opacity(d, &changed);
Ok(())
Ok(((), !changed.is_empty()))
})?;
Ok(json!({ "count": ids.len() }))
}
@@ -393,6 +393,25 @@ fn randomize_opacity_takes_ids_and_leaves_the_rest() {
assert_eq!((opacity(&s, g), opacity(&s, a), opacity(&s, b)), (0.2, 1.0, 1.0));
}
#[test]
fn randomize_opacity_records_no_undo_step_when_no_opacity_changes() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 5.0, 5.0);
let b = rect(&mut s, 10.0, 0.0, 5.0, 5.0);
select(&mut s, &[a, b]);
let n = steps(&s);
// Every object already has the one opacity the range allows.
let r = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 100, "max": 100})).unwrap();
assert_eq!(r["count"], json!(2));
assert_eq!(steps(&s), n);
assert_eq!((opacity(&s, a), opacity(&s, b)), (1.0, 1.0));
s.execute("addon.arrange.randomizeOpacity", &json!({"min": 40, "max": 40})).unwrap();
assert_eq!(steps(&s), n + 1);
s.execute("addon.arrange.randomizeOpacity", &json!({"min": 40, "max": 40})).unwrap();
assert_eq!(steps(&s), n + 1, "the same opacities again");
assert_eq!((opacity(&s, a), opacity(&s, b)), (0.4, 0.4));
}
#[test]
fn randomize_opacity_refuses_bad_params() {
let mut s = session();
+58 -34
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Break Text",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}",
"{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}",
has_selection,
break_text
),
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Combine Point Text",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{ids?: [id…] (default: the selection)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}",
"{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike, as visible and as locked (their groups and layers included), whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}",
has_selection,
combine_text
),
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Join Point Text",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{ids?: [id…] (default: the selection)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}",
"{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one, and as visible and as locked (their groups and layers included), are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}",
has_selection,
join_text
),
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Breaks",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}",
"{kind: \"line\"|\"paragraph\", ids?: [id…] (the type they name is taken locked or hidden too)} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}",
has_selection,
remove_breaks
),
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Alignment, Keep Position",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}",
"{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}",
has_selection,
align_in_place
),
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Text Transform",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}",
"{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}",
has_selection,
reset_transform
),
@@ -123,8 +123,17 @@ fn editing_text(s: &Session) -> std::result::Result<(), String> {
}
}
/// The type objects under `roots` (inside groups and layers, not live art) that can be edited.
fn texts_under(d: &Document, roots: &[NodeId]) -> Result<Vec<NodeId>> {
/// The type objects a command acts on: those under the objects `ids` names, locked and hidden ones
/// included, else those under the selection that can be edited ([`texts_under`]).
fn target_texts(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
let named = p.get("ids").is_some_and(|v| !v.is_null());
texts_under(&s.doc()?.doc, &checked_ids_or_selection(s, p, command)?, named)
}
/// The type objects under `roots` (inside groups and layers, not live art). When `named`, every one
/// not on a locked artboard, locked and hidden ones (and ones in a locked or hidden group or layer)
/// included; else only those that can be edited.
fn texts_under(d: &Document, roots: &[NodeId], named: bool) -> Result<Vec<NodeId>> {
fn visit(n: &Node, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Text(_) => out.push(n.id),
@@ -134,8 +143,13 @@ fn texts_under(d: &Document, roots: &[NodeId]) -> Result<Vec<NodeId>> {
}
let mut out = vec![];
roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out));
let editable = d.editable();
out.retain(|id| editable(*id));
if named {
let locks = d.locked_artboards();
out.retain(|id| !locks.holds_id(*id));
} else {
let editable = d.editable();
out.retain(|id| editable(*id));
}
out.dedup();
if out.len() > MAX_TEXTS {
return Err(EngineError::Other("Too many type objects to change at once".into()));
@@ -376,7 +390,9 @@ fn replace_with_pieces(s: &mut Session, label: &str, items: Vec<(NodeId, Node, V
places.push((id, nodes));
}
d.insert_beside(places, true)?;
sel.set(made.iter().copied());
// Pieces of locked or hidden type stay unselected.
let editable = d.editable();
sel.set(made.iter().copied().filter(|id| editable(*id)));
Ok(made.iter().map(|i| i.0).collect())
})
}
@@ -396,7 +412,7 @@ fn break_text(s: &mut Session, p: &Value) -> Result<Value> {
};
let d = &s.doc()?.doc;
let (mut items, mut skipped, mut count) = (vec![], vec![], 0);
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
let ids = target_texts(s, p, C)?;
let threads = thread_index(d);
for id in ids {
let (Some(src), Some(node)) = (Source::of(d, id, threads.contains_key(&id)), d.node(id)) else {
@@ -450,6 +466,9 @@ fn split_text(s: &mut Session, _: &Value) -> Result<Value> {
struct Piece {
id: NodeId,
t: TextObject,
/// Whether the object and every group and layer above it are visible, and whether they are all
/// unlocked.
state: (bool, bool),
/// The object's turn and shear (`xf` without its move).
turn: [f64; 4],
/// Where its first character's glyph starts on its baseline, in that frame.
@@ -487,13 +506,19 @@ impl Piece {
if !(at.x.is_finite() && at.y.is_finite() && size > 0.0) {
return None;
}
Some((Self { id, t, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node))
let state = d.ancestry(id)?.iter().filter_map(|i| d.node(*i)).fold((true, true), |(v, u), n| (v && n.visible, u && !n.locked));
Some((Self { id, t, state, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node))
}
/// Whether `o` is turned and sheared alike.
fn turned_like(&self, o: &Piece) -> bool {
self.turn.iter().zip(&o.turn).all(|(x, y)| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0))
}
/// Whether `o` is turned and sheared alike and has the same `state`.
fn joins(&self, o: &Piece) -> bool {
self.turned_like(o) && self.state == o.state
}
}
/// Whether two objects look alike apart from their text (paint, opacity, blending, effects).
@@ -595,15 +620,14 @@ fn merge(chain: &[&Piece]) -> std::result::Result<TextObject, usize> {
fn combine_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.combineText";
let d = &s.doc()?.doc;
let mut pieces: Vec<(Piece, &Node)> =
texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id)).collect();
// Lines: pieces turned alike and looking alike, by baseline, then left to right.
let mut pieces: Vec<(Piece, &Node)> = target_texts(s, p, C)?.into_iter().filter_map(|id| Piece::of(d, id)).collect();
// Lines: pieces turned alike, as visible and as locked and looking alike, by baseline, then left
// to right.
pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x)));
let mut lines: Vec<Vec<(&Piece, &Node)>> = vec![];
for (piece, node) in &pieces {
let fits = |l: &Vec<(&Piece, &Node)>| {
l.first()
.is_some_and(|(f, n)| f.turned_like(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size))
l.first().is_some_and(|(f, n)| f.joins(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size))
};
match lines.iter_mut().find(|l| fits(l)) {
Some(l) => l.push((piece, node)),
@@ -672,7 +696,8 @@ fn combine_text(s: &mut Session, p: &Value) -> Result<Value> {
gone.extend(others);
}
d.remove_many(&gone)?;
sel.set(ids.iter().map(|i| NodeId(*i)));
let editable = d.editable();
sel.set(ids.iter().map(|i| NodeId(*i)).filter(|id| editable(*id)));
Ok(())
})?;
Ok(json!({ "ids": ids, "combined": combined }))
@@ -681,15 +706,15 @@ fn combine_text(s: &mut Session, p: &Value) -> Result<Value> {
fn join_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.joinText";
let d = &s.doc()?.doc;
let pieces: Vec<Piece> =
texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect();
// The piece top-left on the page sets the turn; the others turned like it are read in that frame.
let pieces: Vec<Piece> = target_texts(s, p, C)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect();
// The piece top-left on the page sets the turn; the others turned like it, and as visible and as
// locked, are read in that frame.
let on_page = |p: &Piece| Affine::new([p.turn[0], p.turn[1], p.turn[2], p.turn[3], 0.0, 0.0]) * p.at;
let first = pieces.iter().min_by(|x, y| {
let (a, b) = (on_page(x), on_page(y));
a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x))
});
let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| p.turned_like(f)).collect()).unwrap_or_default();
let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| f.joins(p)).collect()).unwrap_or_default();
kept.sort_by(|x, y| x.at.y.total_cmp(&y.at.y).then(x.at.x.total_cmp(&y.at.x)));
// Lines, top to bottom, by baseline; each line's pieces left to right.
let mut lines: Vec<Vec<&Piece>> = vec![];
@@ -751,7 +776,7 @@ fn join_text(s: &mut Session, p: &Value) -> Result<Value> {
n.kind = NodeKind::Text(Box::new(m));
}
d.remove_many(&gone)?;
sel.set([id]);
sel.set([id].into_iter().filter(|id| d.is_editable(*id)));
Ok(())
})?;
Ok(json!({ "id": id.0, "joined": joined, "lines": count }))
@@ -797,8 +822,8 @@ fn apply_cuts(t: &mut TextObject, cuts: &[(usize, bool)], brk: char) -> usize {
/// The breaks `brk` in the text of the frames of `story`, read as one text: a break at the end or
/// start of a frame between words of the frames on either side becomes a space. Each editable frame
/// comes with its own breaks, at its own bytes.
fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(usize, bool)>)> {
/// (each frame when `all`) comes with its own breaks, at its own bytes.
fn story_cuts(d: &Document, story: &[NodeId], brk: char, all: bool) -> Vec<(NodeId, Vec<(usize, bool)>)> {
let texts: Vec<(NodeId, String)> = story.iter().filter_map(|id| Some((*id, text_of(d, *id)?.plain_text()))).collect();
let joined: String = texts.iter().map(|(_, t)| t.as_str()).collect();
// The breaks in story order, handed to the frames in one pass.
@@ -811,7 +836,7 @@ fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(us
while let Some((at, space)) = cuts.next_if(|(at, _)| *at < end) {
own.push((at - start, space));
}
if d.is_editable(id) {
if all || d.is_editable(id) {
out.push((id, own));
}
start = end;
@@ -834,14 +859,14 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
// Each type object with the breaks that go from it.
let targets: Vec<(NodeId, Vec<(usize, bool)>)> = match editing {
Some(_) if !story.is_empty() => story_cuts(d, &story, brk),
Some(_) if !story.is_empty() => story_cuts(d, &story, brk, false),
Some((id, a, b)) => vec![(id, a.min(b), a.max(b))]
.into_iter()
.filter(|(id, ..)| d.is_editable(*id))
.filter_map(|(id, a, b)| Some((id, break_cuts(&text_of(d, id)?.plain_text(), a, b, brk))))
.collect(),
None => {
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
let ids = target_texts(s, p, C)?;
// A threaded story whose frames are all among them is read as one text, once.
let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
let threads = thread_index(d);
@@ -857,7 +882,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
};
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
done.extend(story.iter().copied());
out.extend(story_cuts(d, &story, brk));
// Every frame is among them, so each one keeps its breaks.
out.extend(story_cuts(d, &story, brk, true));
} else if let Some(t) = text_of(d, id) {
out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk)));
}
@@ -891,10 +917,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
/// Horizontal point type among the objects `p` names.
fn point_texts(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = texts_under(d, &checked_ids_or_selection(s, p, cmd)?)?
.into_iter()
.filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point)))
.collect();
let ids: Vec<NodeId> =
target_texts(s, p, cmd)?.into_iter().filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point))).collect();
if ids.is_empty() {
return Err(EngineError::Other("Select point type".into()));
}
@@ -379,6 +379,196 @@ fn remove_breaks_reads_the_threads_once() {
}
}
/// Lock `id` (`locked`), else hide it, as an undo step of its own.
fn lock_or_hide(s: &mut Session, id: NodeId, locked: bool) {
s.edit("Lock or Hide", |d, _| {
let n = d.node_mut(id).unwrap();
if locked {
n.locked = true;
} else {
n.visible = false;
}
Ok(())
})
.unwrap();
}
/// Remove Breaks takes the locked and hidden type that `ids` names, and the locked member of a
/// group it names, as one undo step; from the selection it takes only type that can be edited, and
/// nothing changed makes no undo step.
#[test]
fn remove_breaks_named_locked_and_hidden_type() {
let mut s = session();
let locked = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb", "size": 12}));
let hidden = text(&mut s, json!({"x": 40, "y": 120, "text": "c\nd", "size": 12}));
let grouped = text(&mut s, json!({"x": 40, "y": 180, "text": "e\nf", "size": 12}));
let free = text(&mut s, json!({"x": 40, "y": 240, "text": "g\nh", "size": 12}));
select(&mut s, &[grouped, free]);
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
lock_or_hide(&mut s, locked, true);
lock_or_hide(&mut s, hidden, false);
lock_or_hide(&mut s, grouped, true);
select(&mut s, &[locked, hidden]);
let steps = undo_steps(&s);
assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"}))), "No paragraph breaks to remove");
assert_eq!(undo_steps(&s), steps, "the selected locked and hidden type stays as it is");
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [locked.0, hidden.0, group.0]})).unwrap();
assert_eq!(undo_steps(&s), steps + 1);
assert_eq!(r["removed"], 4);
for (id, now) in [(locked, "a b"), (hidden, "c d"), (grouped, "e f"), (free, "g h")] {
assert_eq!(obj(&s, id).plain_text(), now);
}
assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [locked.0]}))), "No paragraph breaks to remove");
assert_eq!(undo_steps(&s), steps + 1, "nothing changed, no undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, locked).plain_text(), "a\nb");
}
/// A threaded story whose frames `ids` all names is read as one text even when a frame is locked.
#[test]
fn remove_breaks_named_story_with_a_locked_frame() {
let mut s = session();
let (a, b) = threaded(&mut s, "one\ntwo");
lock_or_hide(&mut s, a, true);
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [a.0, b.0]})).unwrap();
assert_eq!(r["removed"], 1);
assert_eq!(obj(&s, a).plain_text(), "one two");
}
/// Type on a locked artboard stays as it is even when `ids` names it, with no undo step.
#[test]
fn remove_breaks_named_type_on_a_locked_artboard() {
let mut s = session();
let on_board = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb", "size": 12}));
s.edit("Lock Artboard", |d, _| {
d.artboards[0].locked = true;
Ok(())
})
.unwrap();
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [on_board.0]}));
assert_eq!(other(r), "No paragraph breaks to remove");
assert_eq!(undo_steps(&s), steps);
assert_eq!(obj(&s, on_board).plain_text(), "a\nb");
}
/// Break Text, Combine and Join Point Text, Set Alignment, Keep Position and Reset Text Transform
/// act on the locked and hidden type that `ids` names (type in a locked layer included), each as
/// one undo step, and leave it as it is when it is only selected.
#[test]
fn named_locked_and_hidden_type() {
let mut s = session();
let words = text(&mut s, json!({"x": 40, "y": 100, "text": "Two words", "size": 14}));
lock_or_hide(&mut s, words, true);
select(&mut s, &[words]);
assert!(s.execute("addon.cleanup.breakText", &json!({"by": "words"})).is_err());
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.breakText", &json!({"by": "words", "ids": [words.0]})).unwrap();
assert_eq!((ids(&r).len(), undo_steps(&s)), (2, steps + 1));
assert!(s.doc().unwrap().selection.objects.is_empty(), "the locked pieces stay unselected");
let mut s = session();
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20}));
let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20}));
lock_or_hide(&mut s, a, false);
lock_or_hide(&mut s, b, false);
select(&mut s, &[a, b]);
assert!(s.execute("addon.cleanup.combineText", &json!({})).is_err());
let r = s.execute("addon.cleanup.combineText", &json!({"ids": [a.0, b.0]})).unwrap();
assert_eq!(ids(&r), [a]);
assert_eq!(obj(&s, a).plain_text(), "Left Right");
assert!(s.doc().unwrap().selection.objects.is_empty(), "the hidden object stays unselected");
let mut s = session();
let first = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20}));
let second = text(&mut s, json!({"x": 50, "y": 130, "text": "Second", "size": 20}));
let layer = s.doc().unwrap().doc.layers[0].id;
lock_or_hide(&mut s, layer, true);
select(&mut s, &[first, second]);
assert!(s.execute("addon.cleanup.joinText", &json!({})).is_err());
let r = s.execute("addon.cleanup.joinText", &json!({"ids": [first.0, second.0]})).unwrap();
assert_eq!(r["id"].as_u64(), Some(first.0));
assert_eq!(obj(&s, first).plain_text(), "First\nSecond");
assert!(s.doc().unwrap().selection.objects.is_empty(), "the object in the locked layer stays unselected");
let mut s = session();
let lines = text(&mut s, json!({"x": 50, "y": 100, "text": "a\nlonger line", "size": 20}));
lock_or_hide(&mut s, lines, false);
select(&mut s, &[lines]);
assert!(s.execute("addon.cleanup.alignInPlace", &json!({"align": "center"})).is_err());
s.execute("addon.cleanup.alignInPlace", &json!({"align": "center", "ids": [lines.0]})).unwrap();
assert!(obj(&s, lines).para_styles().all(|p| p.justify == Justify::Center));
let mut s = session();
let turned = text(&mut s, json!({"x": 300, "y": 200, "text": "Turned", "size": 18, "rotation": 30}));
lock_or_hide(&mut s, turned, true);
select(&mut s, &[turned]);
assert!(s.execute("addon.cleanup.resetTextTransform", &json!({})).is_err());
let steps = undo_steps(&s);
s.execute("addon.cleanup.resetTextTransform", &json!({"ids": [turned.0]})).unwrap();
assert_eq!(undo_steps(&s), steps + 1);
let [_, b, c, _, _, _] = obj(&s, turned).xf.as_coeffs();
assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "the rotation is gone");
}
/// Combine Point Text joins named pieces only with pieces as visible and as locked, a group's lock
/// included: a visible piece and a hidden one beside it, or a free piece and one in a locked group,
/// stay as they are, with no undo step.
#[test]
fn combine_keeps_hidden_and_locked_pieces_apart() {
let mut s = session();
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20}));
let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20}));
lock_or_hide(&mut s, b, false);
let steps = undo_steps(&s);
assert!(s.execute("addon.cleanup.combineText", &json!({"ids": [a.0, b.0]})).is_err());
assert_eq!(undo_steps(&s), steps);
assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), ("Left".into(), "Right".into()));
let d = &s.doc().unwrap().doc;
assert!(d.is_visible(a) && !d.is_visible(b), "the visible text stays shown and the hidden text stays hidden");
let mut s = session();
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20}));
let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20}));
select(&mut s, &[b]);
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
lock_or_hide(&mut s, group, true);
let steps = undo_steps(&s);
assert!(s.execute("addon.cleanup.combineText", &json!({"ids": [a.0, group.0]})).is_err());
assert_eq!(undo_steps(&s), steps);
assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), ("Left".into(), "Right".into()));
}
/// Join Point Text joins only the named pieces as visible and as locked as the top-left one: a
/// hidden piece between two visible lines stays as it is, hidden, and a hidden top-left piece
/// with only a visible one below it joins nothing, with no undo step.
#[test]
fn join_keeps_a_hidden_piece_apart() {
let mut s = session();
let first = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20}));
let hidden = text(&mut s, json!({"x": 50, "y": 130, "text": "Hidden", "size": 20}));
let third = text(&mut s, json!({"x": 50, "y": 160, "text": "Third", "size": 20}));
lock_or_hide(&mut s, hidden, false);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.joinText", &json!({"ids": [first.0, hidden.0, third.0]})).unwrap();
assert_eq!((r["id"].as_u64(), r["joined"].as_u64(), undo_steps(&s)), (Some(first.0), Some(2), steps + 1));
assert_eq!(obj(&s, first).plain_text(), "First\nThird");
assert_eq!(obj(&s, hidden).plain_text(), "Hidden");
let d = &s.doc().unwrap().doc;
assert!(d.is_visible(first) && !d.is_visible(hidden), "the visible text stays shown and the hidden text stays hidden");
assert!(d.node(third).is_none());
let mut s = session();
let top = text(&mut s, json!({"x": 50, "y": 100, "text": "Top", "size": 20}));
let below = text(&mut s, json!({"x": 50, "y": 130, "text": "Below", "size": 20}));
lock_or_hide(&mut s, top, false);
let steps = undo_steps(&s);
assert!(s.execute("addon.cleanup.joinText", &json!({"ids": [top.0, below.0]})).is_err());
assert_eq!(undo_steps(&s), steps);
assert_eq!((obj(&s, top).plain_text(), obj(&s, below).plain_text()), ("Top".into(), "Below".into()));
assert!(s.doc().unwrap().doc.is_visible(below));
}
#[test]
fn split_at_the_cursor_and_around_a_selection() {
for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] {
+3 -3
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply as Filter",
["Object", "Plug-ins", "Color Effects"],
None,
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed}",
has_selection,
filter
),
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Ink Fixes",
["Object", "Plug-ins", "Color Effects"],
None,
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
has_doc,
print_fixes::run
),
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
has_selection,
nearest::apply
),
+2 -1
View File
@@ -320,7 +320,8 @@ fn fresh(doc: &Document, n: &Node, cmd: &str) -> Option<Result<(ColorMask, Optio
/// After an edit from `before` (`None`: everything is new, as after a document's links are read):
/// the color masks of images in `doc`'s art ([`Document::art_roots`]: layers, symbols, graph
/// designs, patterns and the original of the pattern being edited, opacity mask art included)
/// designs, patterns, the original of the pattern being edited and the art of the stored brushes
/// while it is set on the document, opacity mask art included)
/// whose own pixels were replaced are made again for the new pixels ([`Refresh::made`]). Only the
/// art that changed since `before` is looked at.
pub(crate) fn refresh(before: Option<&Document>, doc: &mut Document) {
+11 -4
View File
@@ -529,9 +529,11 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on
/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and
/// when `f` leaves every target as it was, the document and its undo history stay as they are.
/// What the edit changes on the topmost visible fill and stroke of graph series marks, and on the
/// rows and characters of graph labels, is stored on their graphs in the same step
/// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not
/// an edit, and when `f` leaves every target as it was, the document and its undo history stay as
/// they are.
pub(crate) fn edit_items(
s: &mut Session,
ids: &[NodeId],
@@ -541,7 +543,12 @@ pub(crate) fn edit_items(
fill: bool,
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
) -> Result<()> {
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited)))
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| {
let series = capture_series_changes(before, d, edited);
// The rows `f` edited are kept by `edit_items_then`.
let labels = super::graph::capture_label_changes(before, d, edited, false);
Ok(series || labels)
})
}
/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and
+1 -1
View File
@@ -180,7 +180,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paintbrush",
[],
None,
"{…params as path.freehand, brush} paint a freehand stroke with a brush → {id}",
"{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, or a `style` other than \"pencil\" or \"brush\", is an error and nothing is drawn → {id}",
has_doc,
brush_freehand
),
+14 -1
View File
@@ -729,8 +729,21 @@ pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&out) }))
}
/// Paints a `path.freehand` stroke with the brush `brush` names. A `brush` left out or null draws
/// the plain path; a `brush` that is not a string or names no brush in the document, and a `style`
/// other than "pencil" or "brush", is an error before anything is drawn.
pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some()));
const C: &str = "brush.freehand";
let name = match crate::cmd::text_param(p, "brush", C)? {
None => None,
Some(n) if brush::find(&s.doc()?.doc, n).is_some() => Some(n.to_string()),
Some(n) => return Err(bad(C, format!("no brush named `{n}`"))),
};
match p.get("style") {
None | Some(Value::Null) => {}
Some(v) if matches!(v.as_str(), Some("pencil" | "brush")) => {}
Some(v) => return Err(bad(C, format!("`style` must be \"pencil\" or \"brush\", not {v}"))),
}
let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?;
let r = crate::cmd::run_checked(s, spec, p)?;
let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) };
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand",
["Object", "Live Paint"],
None,
"{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of their painted faces and edges → {ids}",
"{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of the faces and edges that draw, with all their fill and stroke rows: a shown face or edge with an opacity above 0 and a shown fill or stroke row that has paint and an opacity above 0 (a stroke also a weight above 0), whichever row is on top → {ids}",
has_selection_or_ids,
lp_expand
),
+50 -17
View File
@@ -1,7 +1,7 @@
use super::{
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point,
Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint,
json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect,
Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json,
number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
};
/// `options` as stored on a Live Paint group: none for the defaults.
@@ -84,6 +84,35 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
/// What Merge carries from a face or edge to the part it makes again in its place, another path:
/// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep
/// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind.
struct Carried {
appearance: Appearance,
visible: bool,
opacity: f32,
blend: vectorcraft_color::BlendMode,
}
impl Carried {
fn of(n: &Node) -> Self {
let mut appearance = (*n.appearance).clone().without_pressure();
if appearance.has_unplaced_gradient()
&& let Some(bounds) = n.geometric_bounds()
{
appearance.pin_gradients(bounds);
}
Self { appearance, visible: n.visible, opacity: n.opacity, blend: n.blend }
}
fn onto(self, n: &mut Node) {
n.appearance = self.appearance.into();
n.visible = self.visible;
n.opacity = self.opacity;
n.blend = self.blend;
}
}
/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its
/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which
/// are merged; returns (group, faces, edges).
@@ -95,18 +124,16 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
check_live_paint_takes(d, roots)?;
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
// Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge,
// paint) for every edge.
let keep_faces: Vec<(Point, Carried)> =
b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect();
let keep_edges: Vec<(Point, Carried)> = b::edges(&old)
.iter()
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e)))
})
.collect();
let mut sources = source_nodes(&old);
@@ -126,20 +153,20 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
*ch = children;
}
g.extras_mut().live_paint_gaps = stored_gap_options(options);
for (pt, paint) in keep_faces {
for (pt, carried) in keep_faces {
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
&& let Some(ch) = g.children_mut()
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
{
Arc::make_mut(f).appearance.set_fill(paint);
carried.onto(Arc::make_mut(f));
}
}
for (pt, app) in keep_edges {
for (pt, carried) in keep_edges {
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app.into();
carried.onto(Arc::make_mut(e));
}
}
let mut merged = Beside::replacing(d, roots);
@@ -191,6 +218,13 @@ pub(super) fn lp_release(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids) }))
}
/// Does face or edge `n` draw on the canvas: it is shown, its opacity is above 0, and a fill or
/// stroke row of its appearance draws (see [`Appearance::has_drawn_fill`] and
/// [`Appearance::has_drawn_stroke`])?
fn draws(n: &Node) -> bool {
n.visible && n.opacity > 0.0 && (n.appearance.has_drawn_fill() || n.appearance.has_drawn_stroke())
}
pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
let targets = live_targets(s, p)?;
s.edit("Expand Live Paint", |d, sel| {
@@ -205,8 +239,7 @@ pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(ch) = n.children_mut() {
ch.retain(|c| match c.name.as_deref() {
Some(SOURCES_NAME) => false,
Some(FACE_NAME) => !c.appearance.fill_paint().is_none(),
Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0,
Some(FACE_NAME | EDGE_NAME) => draws(c),
_ => true,
});
}
+12 -8
View File
@@ -526,12 +526,15 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec<Arc<Node>>) -> Result<Ve
.collect())
}
/// What a trace depends on: the object traced (an image, or an Image Trace object traced again),
/// its image, the image's encoded bytes, and the settings it is traced with. The tracer makes the
/// same paths of the same inputs, so a trace made away from the command ([`TraceRun`]) stands in
/// for the command's own.
/// What a trace depends on: the document it is made in, the object traced (an image, or an Image
/// Trace object traced again), its image, the image's encoded bytes, and the settings it is traced
/// with. The tracer makes the same paths of the same inputs, so a trace made away from the command
/// ([`TraceRun`]) stands in for the command's own in the same document.
#[derive(Clone, Debug, PartialEq)]
struct TraceInputs {
/// The [`DocState::uid`](crate::DocState::uid) of the document traced in. A trace made in one
/// document never stands in for one in another, however alike their images and settings.
doc: u64,
target: NodeId,
image: Box<ImageObject>,
/// The settings traced with, as JSON.
@@ -600,7 +603,7 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result<Prepared> {
let bytes = Arc::clone(&blob.bytes);
let library = trace_library(c, s, &mut params)?;
let traced_with = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
let inputs = TraceInputs { target, image, params: traced_with, swatches: params.swatches.clone(), bytes };
let inputs = TraceInputs { doc: s.doc()?.uid, target, image, params: traced_with, swatches: params.swatches.clone(), bytes };
Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library })
}
@@ -612,8 +615,9 @@ pub struct Traced {
}
impl Traced {
/// Hand it to the next run of its command with the same inputs ([`TraceRun::params`]), which
/// applies it as one undo step instead of tracing again. Any other trace drops it.
/// Hand it to the next run of its command with the same inputs ([`TraceRun::params`]) in the
/// document it was made in, which applies it as one undo step instead of tracing again. Any
/// other trace drops it, a trace in another document included.
pub fn hand_in(self, s: &mut Session) {
s.trace_hand_off = Some(self);
}
@@ -686,7 +690,7 @@ impl TraceRun {
}
/// Does it still apply: would the command trace the same object, image, pixels and settings
/// now?
/// now, in the document it was prepared in?
pub fn current(&self, s: &Session) -> bool {
prepare(s, self.command, &self.params).is_ok_and(|now| now.inputs == self.prepared.inputs)
}
+6 -4
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Invert Colors",
["Edit", "Edit Colors"],
None,
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
has_selection,
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
),
@@ -513,14 +513,16 @@ pub(crate) fn edit_if_changed<T>(s: &mut Session, label: &str, f: impl FnOnce(&D
}
/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed
/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn
/// again.
/// on the graph series marks and graph labels among `roots` and inside them, so the marks and every label of a kind keep
/// their new paint when the graph is drawn again.
pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result<usize>) -> Result<usize> {
let marks = super::graph::series_marks_in(d, roots);
let before = (!marks.is_empty()).then(|| d.clone());
let labels = super::graph::labels_in(d, roots);
let before = (!marks.is_empty() || !labels.is_empty()).then(|| d.clone());
let n = recolor(d)?;
if let Some(before) = before.filter(|_| n > 0) {
super::appearance::capture_series_changes(&before, d, &marks);
super::graph::capture_label_changes(&before, d, &labels, true);
}
Ok(n)
}
+25 -1
View File
@@ -9,7 +9,8 @@
//! ([`use_front_profiles`]): when they are assigned, when the working settings change, and when the
//! document comes to the front or its assignment comes back (an open, a switch, a close, a revert,
//! an undo or a rollback, [`Session::sync_front_profiles`]); the working spaces stay as Edit → Color
//! Settings set them.
//! Settings set them. A document drawn while another is in front draws through its own profiles
//! ([`doc_cms`]).
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -236,6 +237,9 @@ fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
} else if proof_targets(&info.name) {
// A new file of the CMYK profile View → Proof Setup targets: redraw.
touch_all(s);
} else {
// Documents behind the front one draw through their own profiles: those assigned it redraw.
touch_assigned(s, &info.name);
}
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
}
@@ -266,9 +270,21 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
if proof_targets(&info.name) {
touch_all(s);
}
// Documents behind the front one draw through their own profiles: those assigned it redraw.
touch_assigned(s, &info.name);
Ok(json!({"removed": info.name}))
}
/// Redraw the open documents assigned the profile `name`.
fn touch_assigned(s: &mut Session, name: &str) {
for d in &mut s.docs {
let (rgb, cmyk) = doc_profiles(&d.doc);
if [rgb, cmyk].iter().any(|n| n.as_deref() == Some(name)) {
d.revision += 1;
}
}
}
/// Whether View → Proof Setup targets the CMYK profile `name`.
fn proof_targets(name: &str) -> bool {
matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name)
@@ -285,6 +301,14 @@ pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option<String>, Option<St
(get(&d.color_profiles.rgb), get(&d.color_profiles.cmyk))
}
/// The color-management context the document draws through: the working settings with its
/// assigned profiles in place of the working spaces, whether or not it is in front
/// ([`cms::for_document`]).
pub fn doc_cms(d: &vectorcraft_doc::Document) -> Arc<cms::Cms> {
let (rgb, cmyk) = doc_profiles(d);
cms::for_document(rgb.as_deref(), cmyk.as_deref())
}
fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.assignProfile";
let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc);
@@ -478,6 +478,75 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// The label of dimension `id` in `doc`.
fn label(doc: &vectorcraft_doc::Document, id: u64) -> vectorcraft_doc::TextObject {
let node = doc.node(NodeId(id)).unwrap();
node.children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap()
}
/// The center of label `t`'s measured box on the page.
fn label_center(t: &vectorcraft_doc::TextObject) -> vectorcraft_geom::Point {
t.xf * t.cached_bounds.unwrap().center()
}
/// Whether every character of label `t` is filled in CMYK.
fn label_in_cmyk(t: &vectorcraft_doc::TextObject) -> bool {
!t.runs.is_empty() && t.runs.iter().all(|r| matches!(r.style.fill.color(), Some(vectorcraft_color::Color::Cmyk { .. })))
}
/// The native file of a CMYK document whose composite font `name` sets Roman at full size, with a
/// dimension whose label is set in it and whose label's characters are saved in RGB, so opening
/// the file draws the dimension again → (the file, the dimension's id, the center of its label as
/// drawn in the document's own composite font).
fn dimension_file(name: &str) -> (Vec<u8>, u64, vectorcraft_geom::Point) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400, "colorMode": "cmyk"})).unwrap();
let mut full = font(name);
full["parts"]["roman"]["size"] = json!(100);
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
let line = s.execute("shape.line", &json!({"x1": 100, "y1": 100, "x2": 172, "y2": 100})).unwrap()["id"].as_u64().unwrap();
let id = s
.execute(
"dimension.create",
&json!({"source": {"kind": "segment", "edge": {"node": line, "subpath": 0, "segment": 0}},
"options": {"units": "in", "precision": 2, "fontFamily": name, "fontSize": 20}}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
let mut doc = (*s.doc().unwrap().doc).clone();
let drawn = label(&doc, id);
assert!(label_in_cmyk(&drawn));
let center = label_center(&drawn);
let mut label_id = None;
doc.node(NodeId(id)).unwrap().walk(&mut |n| {
if matches!(n.kind, NodeKind::Text(_)) {
label_id = Some(n.id);
}
});
if let Some(NodeKind::Text(t)) = doc.node_mut(label_id.unwrap()).map(|n| &mut n.kind) {
for r in &mut t.runs {
r.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0));
}
}
(vectorcraft_format::save_file(&doc), id, center)
}
/// A file whose dimensions are drawn again when it opens (their art is not in the document's color
/// mode) draws their labels in its own composite fonts, not in those of the front document, which
/// defines the label's font name with Roman at half size: each label stays centered on its anchor.
#[test]
fn an_opened_file_draws_its_dimension_labels_in_its_own_composite_fonts() {
let (bytes, id, center) = dimension_file("CF Dimension");
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Dimension")]})).unwrap();
let file = crate::cmd::fileio::read_for_open("dimension.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
crate::cmd::fileio::open_read(&mut s, file).unwrap();
let opened = label(&s.doc().unwrap().doc, id);
assert!(label_in_cmyk(&opened));
assert!((label_center(&opened) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&opened));
}
/// Threaded type read from a file flows through its frames in the file's own composite fonts, when
/// no open document defines them.
#[test]
@@ -536,6 +605,40 @@ fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// A document replaced in front (File › Revert) whose dimensions are drawn again draws their labels
/// in its new composite fonts, not in those it had before.
#[test]
fn a_document_replaced_in_front_draws_its_dimension_labels_in_its_own_composite_fonts() {
let (bytes, id, center) = dimension_file("CF Reverted");
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Reverted")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
let replaced = label(&s.doc().unwrap().doc, id);
assert!(label_in_cmyk(&replaced));
assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced));
}
/// A document replaced in the back draws its dimension labels in its own composite fonts, while the
/// front document keeps its definition of the same name registered; the labels stay where they are
/// when the document comes to the front.
#[test]
fn a_document_replaced_in_the_back_draws_its_dimension_labels_in_its_own_composite_fonts() {
let (bytes, id, center) = dimension_file("CF Back Dimension");
let mut s = session();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Dimension")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
assert_eq!(s.active_index(), Some(1));
let front = vectorcraft_text::FontDb::global().composite_font("CF Back Dimension").unwrap();
assert_eq!(front.parts.get(vectorcraft_doc::CharClass::Roman).size, 50.0);
let replaced = label(&s.documents()[0].doc, id);
assert!(label_in_cmyk(&replaced));
assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced));
assert!(s.set_active(0));
let shown = label(&s.doc().unwrap().doc, id);
assert!((label_center(&shown) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&shown));
}
/// The type of document `index` with id `id`, wherever its tab is.
fn text_in(s: &Session, index: usize, id: u64) -> vectorcraft_doc::TextObject {
match &s.documents()[index].doc.node(NodeId(id)).unwrap().kind {
+6 -1
View File
@@ -495,7 +495,8 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
/// Redraw the dimensions of an opened document whose art isn't in its colour mode: files saved
/// before dimensions followed the colour mode have RGB dimensions in CMYK documents. A dimension
/// that can't be redrawn keeps the art it has.
/// that can't be redrawn keeps the art it has. The labels are measured in the document's own
/// composite fonts, also when another document's definitions of the same names are registered.
pub(crate) fn conform(doc: &mut Document) {
if !doc.dimensions {
return;
@@ -521,6 +522,10 @@ pub(crate) fn conform(doc: &mut Document) {
}
}
});
if stale.is_empty() {
return;
}
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
for (id, d) in stale {
// An error leaves this dimension as it was; the others are still redrawn.
let _ = replace(doc, id, d);
+2 -2
View File
@@ -26,7 +26,7 @@ pub use perspective::perspective_click;
use perspective::{
attach, edit_text, grid_set, grid_show, persp_draw, persp_move, persp_nudge, persp_transform, plane_match, plane_move, plane_set, release,
};
pub(crate) use perspective::{finish_edit_text, grid_of, perspective_rewrite, silent, store_grid, transform_again};
pub(crate) use perspective::{finish_edit_text, grid_kept, grid_of, perspective_rewrite, silent, store_grid, transform_again};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -107,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Perspective Grid Preset",
[],
None,
"{kind: 1|2|3 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached)",
"{kind: 1|2|3 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached). No undo step when the document already holds that grid; when only whether it shows or its active plane differs, those are set without one",
has_doc,
super::perspgrid::grid_preset
),
@@ -18,7 +18,7 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g`
/// leaves `d` as it is.
fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool {
pub(crate) fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool {
let mut g = g.clone();
g.attached.clear();
serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v))
+3 -71
View File
@@ -344,44 +344,9 @@ fn spot_swatches(d: &Document) -> HashSet<&str> {
first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect()
}
/// Which of gradient `g`'s stops color some part of it that draws, by the stretches
/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k`
/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s,
/// and both its stops count when that stretch has positive width and either stop is above opacity
/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its
/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop,
/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width
/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from
/// its points, so each of its stops counts while any stop is above opacity 0.
fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec<bool> {
let stops = &g.stops;
if g.kind == vectorcraft_color::GradientKind::Freeform {
let any = stops.iter().any(|s| s.opacity > 0.0);
return vec![any; stops.len()];
}
let mut drawn = vec![false; stops.len()];
if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) {
*d = first.opacity > 0.0;
}
if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) {
*d |= last.opacity > 0.0;
}
// `f32::max` skips a NaN offset, which the sampler never picks either.
let mut reach = f32::NEG_INFINITY;
for k in 0..stops.len().saturating_sub(1) {
reach = reach.max(stops[k].offset);
let (a, b) = (&stops[k], &stops[k + 1]);
if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) {
drawn[k] = true;
drawn[k + 1] = true;
}
}
drawn
}
/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is
/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the
/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a
/// linked to, or those a gradient's drawn stops ([`vectorcraft_color::Gradient::drawn_stops`]) are
/// linked to, each once, in the order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a
/// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not
/// linked to one.
fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
@@ -398,7 +363,7 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
match p {
Paint::Solid { swatch, tint, .. } => add(swatch, *tint),
Paint::Gradient(g) => {
let drawn = drawn_stops(&g.gradient);
let drawn = g.gradient.drawn_stops();
g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint));
}
_ => {}
@@ -1662,39 +1627,6 @@ mod tests {
assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}");
}
/// A gradient's stops print where they color a stretch that draws, for stops in any order: the
/// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an
/// out-of-order stop's pair can have no width.
#[test]
fn drawn_stops_follow_the_sampled_stretches() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop};
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// In order: the clear stop next to the opaque one, not the one after it.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]);
// Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with
// the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]);
// An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the
// pairs next to the stop at 0.3 have no width and it colors nothing.
let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]);
// With the stop at 0.8 opaque, the pairs next to it with width are drawn.
assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]);
// The last stop colors the offsets above every stop's, even when it is out of order.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]);
// The first stop colors the offsets up to its own.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]);
// One stop, every stop clear, and no stops at all.
assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]);
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]);
assert!(drawn_stops(&g(vec![])).is_empty());
// A freeform gradient draws from its points: its stops count while any is above opacity 0.
let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]);
free.kind = GradientKind::Freeform;
assert_eq!(drawn_stops(&free), vec![true, true, true]);
}
/// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object,
/// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of
/// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no
+169 -2
View File
@@ -49,6 +49,84 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec<A
out
}
/// Distance (points) from an anchor within which a smoothed closed path starts at that anchor
/// instead of at a new one split in beside it.
const START_SNAP: f64 = 1e-3;
/// Distance (points) within which two points of a smoothed closed path count as equally near its
/// old start.
const START_TIE: f64 = 1e-6;
/// Where `p` lies along the closed `orig` from its first anchor: the segment index and parameter of
/// the point of `orig` nearest `p`, the first such point in its order. Sorts after every finite place
/// when there is none.
fn place_along(orig: &SubPath, p: Point) -> (usize, f64) {
let mut best: Option<(f64, usize, f64)> = None;
for j in 0..orig.anchors.len() {
let hit = orig.segment(j).nearest(p, 1e-9);
if hit.distance_sq.is_finite() && hit.t.is_finite() && best.is_none_or(|(d, ..)| hit.distance_sq < d) {
best = Some((hit.distance_sq, j, hit.t));
}
}
best.map_or((usize::MAX, f64::INFINITY), |(_, j, t)| (j, t))
}
/// The closed `sp` started at the point on it nearest `start`: at the anchor there, or at a new
/// anchor split into the segment there, which leaves the outline as it is. Of points equally near
/// `start`, the one earliest along `orig` (the path before smoothing) from its first anchor is used,
/// then the first in `sp`'s order. An open `sp`, or one with no segment or no finite nearest point,
/// is returned as it is.
fn start_nearest(mut sp: SubPath, start: Point, orig: &SubPath) -> SubPath {
let n = sp.anchors.len();
if !sp.closed || n < 2 {
return sp;
}
// (distance, place along `orig` once a tie needs it, segment of `sp`, parameter)
type Best = (f64, Option<(usize, f64)>, usize, f64);
let mut best: Option<Best> = None;
for i in 0..n {
let c = sp.segment(i);
let hit = c.nearest(start, 1e-9);
if !hit.distance_sq.is_finite() || !hit.t.is_finite() {
continue;
}
let d = hit.distance_sq.sqrt();
match &mut best {
None => best = Some((d, None, i, hit.t)),
Some((bd, bplace, bi, bt)) if (d - *bd).abs() <= START_TIE => {
let held = *bplace.get_or_insert_with(|| place_along(orig, sp.segment(*bi).eval(*bt)));
let place = place_along(orig, c.eval(hit.t));
if place.0 < held.0 || (place.0 == held.0 && place.1 < held.1) {
best = Some((d, Some(place), i, hit.t));
}
}
Some((bd, ..)) if d < *bd => best = Some((d, None, i, hit.t)),
Some(_) => {}
}
}
let Some((_, _, i, t)) = best else { return sp };
let c = sp.segment(i);
let at = c.eval(t);
let first = if at.distance(c.p0) <= START_SNAP {
i
} else if at.distance(c.p3) <= START_SNAP {
(i + 1) % n
} else {
let mid = if sp.segment_is_line(i) {
Anchor::corner(at)
} else {
let (l, r) = (c.subsegment(0.0..t), c.subsegment(t..1.0));
sp.anchors[i].h_out = l.p1;
sp.anchors[(i + 1) % n].h_in = r.p2;
Anchor::with_handles(at, l.p2, r.p1)
};
sp.anchors.insert(i + 1, mid);
i + 1
};
sp.anchors.rotate_left(first);
sp
}
/// Pieces of curve that one Smooth drag measures against the drag while it searches the segments
/// under it ([`reach_samples`]), shared equally by the curved segments that come within its reach,
/// each of which gets at least one: enough to search 300,000 pt of curve within the default reach
@@ -226,6 +304,9 @@ fn mark_reached(sp: &SubPath, pts: &[Point], radius: f64, share: usize) -> (SubP
(out, marked)
}
/// `sp` with the anchors in the drag's reach smoothed and refitted, `None` when none are in reach.
/// A closed path whose first anchor is in reach starts at the point of the refitted path nearest
/// that anchor's place (VectorCraft's own rule).
fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f64, sigma: f64) -> Option<SubPath> {
if sp.anchors.len() < 2 {
return None;
@@ -238,10 +319,11 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
return None;
}
if sp.closed {
let start = sp.anchors[0].p;
let Some(k) = marked.iter().position(|m| !m) else {
// Everything is in range: smooth the whole closed path, across its start too.
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
return f.subpaths.into_iter().next();
return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp));
};
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
let mut open = sp.clone();
@@ -256,7 +338,9 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
if let Some(r0) = res.first_mut() {
r0.h_in = l.h_in;
}
return Some(SubPath::new(res, true));
// With k = 0 the first anchor is out of reach and still starts the result.
let res = SubPath::new(res, true);
return Some(if k == 0 { res } else { start_nearest(res, start, sp) });
}
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false))
}
@@ -621,6 +705,89 @@ pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
/// A closed ring of four smooth anchors round (100, 100) with radius 50.
fn ring() -> SubPath {
let k = 0.5523 * 50.0;
let a = |x: f64, y: f64, dx: f64, dy: f64| Anchor::with_handles(Point::new(x, y), Point::new(x - dx, y - dy), Point::new(x + dx, y + dy));
SubPath::new(vec![a(150.0, 100.0, 0.0, k), a(100.0, 150.0, -k, 0.0), a(50.0, 100.0, 0.0, -k), a(100.0, 50.0, k, 0.0)], true)
}
/// A start between two anchors of a curve splits that curve there into two that trace it, and the
/// new anchor comes first.
#[test]
fn start_nearest_splits_a_curve_where_it_passes_the_start() {
let sp = ring();
let old = sp.segment(0);
let start = old.eval(0.3) + (old.eval(0.3) - Point::new(100.0, 100.0)) * 0.1;
let out = start_nearest(sp.clone(), start, &sp);
assert!(out.closed && out.anchors.len() == 5, "{out:?}");
let t = old.nearest(start, 1e-9).t;
assert!(out.anchors[0].p.distance(old.eval(t)) < 1e-9, "{out:?}");
let (head, rest) = (out.segment(4), out.segment(0));
for k in 0..=8 {
let u = f64::from(k) / 8.0;
assert!(head.eval(u).distance(old.eval(t * u)) < 1e-9);
assert!(rest.eval(u).distance(old.eval(t + (1.0 - t) * u)) < 1e-9);
}
for i in 1..4 {
assert_eq!(out.segment(i), sp.segment(i), "segment {i} stays");
}
}
/// A start within [`START_SNAP`] of an anchor starts the ring at that anchor, with no anchor added.
#[test]
fn start_nearest_starts_at_an_anchor_near_the_start() {
let sp = ring();
let out = start_nearest(sp.clone(), Point::new(50.0, 100.0 + START_SNAP / 2.0), &sp);
let mut want = sp.anchors.clone();
want.rotate_left(2);
assert_eq!(out.anchors, want);
}
/// A start on a straight side of a triangle adds a corner point there without handles, and the
/// sides stay straight.
#[test]
fn start_nearest_splits_a_line_with_a_corner_point() {
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(50.0, 80.0)], true);
let out = start_nearest(sp.clone(), Point::new(40.0, -5.0), &sp);
let want: Vec<Point> = [(40.0, 0.0), (100.0, 0.0), (50.0, 80.0), (0.0, 0.0)].map(|(x, y)| Point::new(x, y)).to_vec();
assert!(out.anchors.len() == 4 && out.anchors.iter().zip(&want).all(|(a, w)| a.p.distance(*w) < 1e-9), "{out:?}");
assert!((0..4).all(|i| out.segment_is_line(i)), "{out:?}");
}
/// A ring whose last anchor lies on its first keeps its first anchor first, and gets no third
/// anchor at that place.
#[test]
fn start_nearest_keeps_the_first_of_two_anchors_on_one_place() {
let p = [(0.0, 0.0), (100.0, 0.0), (50.0, 80.0), (0.0, 0.0)].map(|(x, y)| Point::new(x, y));
let sp = SubPath::polyline(&p, true);
assert_eq!(start_nearest(sp.clone(), p[0], &sp), sp);
}
/// Of points equally near the start, the one earliest along the path before smoothing from its
/// first anchor comes first, whatever the smoothed path's order. The center of a square is equally
/// near the middle of each side. The square before smoothing starts at (-50, -50), so the middle
/// of its first side, (0, -50), comes first, though the smoothed copy starts at (50, 50).
#[test]
fn start_nearest_breaks_a_tie_by_the_order_before_smoothing() {
let p = [(-50.0, -50.0), (50.0, -50.0), (50.0, 50.0), (-50.0, 50.0)].map(|(x, y)| Point::new(x, y));
let orig = SubPath::polyline(&p, true);
let mut sp = orig.clone();
sp.anchors.rotate_left(2);
let out = start_nearest(sp, Point::new(0.0, 0.0), &orig);
assert!(out.closed && out.anchors.len() == 5, "{out:?}");
assert!(out.anchors[0].p.distance(Point::new(0.0, -50.0)) < 1e-9, "{out:?}");
assert!(out.anchors[1].p.distance(p[1]) < 1e-9, "{out:?}");
}
/// An open path, and a start that is not a number, leave the path as it is.
#[test]
fn start_nearest_leaves_an_open_path_or_a_start_without_a_place() {
let open = SubPath { closed: false, ..ring() };
assert_eq!(start_nearest(open.clone(), Point::new(100.0, 50.0), &ring()), open);
assert_eq!(start_nearest(ring(), Point::new(f64::NAN, 0.0), &ring()), ring());
}
/// Searching a curve 1,000,000 pt long under a drag of 1,000 points along it measures no more
/// pieces than the budget it is given, and a search with none left gives one sample.
#[test]
+1 -1
View File
@@ -70,7 +70,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode,
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
pub use export::{export_doc, export_source, unused_option_notes};
pub use export::{export_doc, export_source, selection_doc, unused_option_notes};
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::is_placeholder;
+192 -13
View File
@@ -12,6 +12,8 @@ use serde_json::json;
use super::super::{Result, Session, bad};
#[cfg(not(target_arch = "wasm32"))]
use super::super::{check_switches, find_command, switch_or, text_param};
#[cfg(not(target_arch = "wasm32"))]
use crate::EngineError;
#[cfg(target_arch = "wasm32")]
pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result<Value> {
@@ -58,6 +60,10 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
if id == "command.batch" || id == "action.batch" {
return Err(bad(C, "batches cannot nest"));
}
// `command.batch` refuses the batch when it reaches this step, which would fail every file or data set it plays.
if id == "prefs.import" {
return Err(bad(C, format!("commands[{i}]: `prefs.import` runs on its own, not as a batch step")));
}
if find_command(id).is_none() {
return Err(bad(
C,
@@ -213,7 +219,7 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run:
// open, so its artboard files count ([`dataset_output_paths`]). No output takes the name of a
// source file in the destination folder, other than that file's own output, or of a subfolder
// of the destination folder that holds source files.
let steps = filter_steps(run.steps, override_open, false);
let (steps, places) = filter_steps(run.steps, override_open, false);
let run = Run { steps: &steps, ..*run };
let in_dest: Vec<Option<(String, bool)>> = files.iter().map(|path| name_in_folder(path, run.dest_folder)).collect();
let mut picker =
@@ -228,7 +234,7 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run:
let outputs = dataset_output_extensions(&run, doc, &ext);
picker.pick(&base, |stem| output_names(run.dest_folder, stem, &outputs, doc), own.as_deref())
};
match process_one_file(s, &run, path, &ext, pick) {
match process_one_file(s, &run, &places, path, &ext, pick) {
Ok(row) => processed.push(row),
Err(e) => {
failed += 1;
@@ -266,12 +272,12 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
}
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
let stems = dataset_stems(&names, &doc_stem, run.dataset_file_naming, run, &st.doc)?;
let steps = filter_steps(run.steps, true, false);
let (steps, places) = filter_steps(run.steps, true, false);
let run = Run { steps: &steps, ..*run };
let mut processed = Vec::new();
let mut failed = 0usize;
for (name, stem) in names.iter().zip(&stems) {
match process_one_dataset(s, &run, name, stem) {
match process_one_dataset(s, &run, &places, name, stem) {
Ok(row) => processed.push(row),
Err(e) => {
failed += 1;
@@ -347,11 +353,13 @@ impl StemPicker {
}
}
/// Drop Open (and optionally Close) steps when the batch supplies the file itself.
/// Drop Open (and optionally Close) steps when the batch supplies the file itself → the steps that
/// stay, and the index of each in `cmds`.
#[cfg(not(target_arch = "wasm32"))]
fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec<Value> {
fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> (Vec<Value>, Vec<usize>) {
cmds.iter()
.filter(|c| {
.enumerate()
.filter(|(_, c)| {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
if drop_open && matches!(id, "document.open" | "file.open") {
return false;
@@ -361,14 +369,44 @@ fn filter_steps(cmds: &[Value], drop_open: bool, drop_close: bool) -> Vec<Value>
}
true
})
.cloned()
.collect()
.map(|(i, c)| (c.clone(), i))
.unzip()
}
/// Plays `commands`, the steps of `run` with their paths rewritten, as one `command.batch`. When a
/// step fails, the error names it by its place in the caller's `commands`, from 1, counting the Open
/// steps the batch skipped (`places` gives the index there of each step of `run`):
/// ``action step 3 (`object.move`) failed: …``. Any other error, such as one about the whole batch,
/// stays as it is.
#[cfg(not(target_arch = "wasm32"))]
fn play_steps(s: &mut Session, run: &Run<'_>, places: &[usize], commands: Vec<Value>) -> Result<Value> {
s.execute("command.batch", &json!({"label": run.label, "commands": commands})).map_err(|e| {
let text = e.to_string();
let Some((n, id, reason)) = batch_step_failure(&text) else { return e };
let same = run.steps.get(n).and_then(|c| c.get("command")).and_then(Value::as_str) == Some(id);
match places.get(n) {
Some(place) if same => EngineError::Other(format!("action step {} (`{id}`) failed: {reason}", place + 1)),
_ => e,
}
})
}
/// The step number, command id and reason in the error `command.batch` gives when one of its steps
/// fails (``batch step 2 (`object.move`) failed: …``); none for any other error.
#[cfg(not(target_arch = "wasm32"))]
fn batch_step_failure(e: &str) -> Option<(usize, &str, &str)> {
let rest = e.strip_prefix("batch step ")?;
let digits = rest.find(|c: char| !c.is_ascii_digit())?;
let n = rest[..digits].parse().ok()?;
let (id, reason) = rest[digits..].strip_prefix(" (`")?.split_once("`) failed: ")?;
Some((n, id, reason))
}
#[cfg(not(target_arch = "wasm32"))]
fn process_one_file(
s: &mut Session,
run: &Run<'_>,
places: &[usize],
path: &str,
ext: &str,
stem_for: impl FnOnce(&vectorcraft_doc::Document) -> String,
@@ -376,17 +414,17 @@ fn process_one_file(
s.execute("document.open", &json!({"path": path}))?;
let stem = stem_for(&s.doc()?.doc);
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, ext);
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
let batch = play_steps(s, run, places, rewritten)?;
// Folder sources open a file per pass: close after Save and Close / Folder.
finish_destination(s, run, &stem, ext, path, true)?;
Ok(json!({"path": path, "result": batch}))
}
#[cfg(not(target_arch = "wasm32"))]
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, stem: &str) -> Result<Value> {
fn process_one_dataset(s: &mut Session, run: &Run<'_>, places: &[usize], name: &str, stem: &str) -> Result<Value> {
s.execute("dataset.select", &json!({"name": name}))?;
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, "vectorcraft");
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
let batch = play_steps(s, run, places, rewritten)?;
// Data sets share one open document: write each row out but leave it open for the next.
finish_destination(s, run, stem, "vectorcraft", "", false)?;
Ok(json!({"dataset": name, "result": batch}))
@@ -1454,7 +1492,8 @@ mod tests {
assert_eq!((r["failed"].as_u64(), r["processed"].as_array().map(Vec::len)), (Some(rows), Some(rows as usize)), "{r}");
for row in r["processed"].as_array().unwrap() {
let e = row["error"].as_str().unwrap_or_default();
let want = format!("batch step 0 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft");
let want =
format!("action step 1 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft");
assert_eq!(e, want, "{r}");
}
assert!(!r.to_string().contains(&hidden), "{r}");
@@ -2224,4 +2263,144 @@ mod tests {
.is_err()
);
}
/// The steps of the step-number tests: an Open the batch skips, a step that works, and one that
/// fails (an Align with nothing selected).
fn open_then_failing_align() -> Value {
json!([
{"command": "document.open", "params": {"path": "/no/such/file.svg"}},
{"command": "object.unlockAll", "params": {}},
{"command": "object.align", "params": {}}
])
}
/// The step a source's error names, after `action step ` and before ` failed: `.
fn named_step(r: &Value, row: usize) -> String {
let e = r["processed"][row]["error"].as_str().unwrap_or_default();
assert!(!e.contains("batch step"), "{r}");
e.strip_prefix("action step ").and_then(|e| e.split_once(" failed: ")).map(|(step, _)| step.to_string()).unwrap_or_else(|| e.to_string())
}
/// A failed step of a folder batch is named by its place in the `commands` sent, from 1, with the
/// Open step that Override Open skips counted; the reason follows unchanged.
#[test]
fn a_folder_batch_error_names_the_step_in_the_commands_sent() {
let src = tmp("step-number-src");
write_svg(&src, "a.svg", "#f00");
write_svg(&src, "b.svg", "#0f0");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
// `command.batch` itself still numbers its steps from 0.
let reason = s
.execute(
"command.batch",
&json!({"commands": [{"command": "object.unlockAll", "params": {}}, {"command": "object.align", "params": {}}]}),
)
.unwrap_err()
.to_string();
let reason = reason.strip_prefix("batch step 1 (`object.align`) failed: ").unwrap_or_else(|| panic!("{reason}")).to_string();
for (stop, rows) in [(true, 1), (false, 2)] {
let r = s
.execute("action.batch", &json!({"folder": src.to_string_lossy(), "stopOnError": stop, "commands": open_then_failing_align()}))
.unwrap();
assert_eq!(r["failed"], rows, "{r}");
for row in 0..rows {
assert_eq!(r["processed"][row]["error"], format!("action step 3 (`object.align`) failed: {reason}"), "{r}");
}
}
}
/// With Override Open off, a recorded Open step runs, and its failure is named as the first step.
#[test]
fn a_recorded_open_that_fails_is_the_first_step() {
let src = tmp("step-number-open-src");
write_svg(&src, "a.svg", "#f00");
let mut s = Session::new();
let r = s
.execute("action.batch", &json!({"folder": src.to_string_lossy(), "overrideOpen": false, "commands": open_then_failing_align()}))
.unwrap();
assert_eq!(named_step(&r, 0), "1 (`document.open`)", "{r}");
}
/// A data set batch, which always skips Open steps, names a failed step by its place in the
/// `commands` sent for every data set it fails on.
#[test]
fn a_data_set_batch_error_names_the_step_in_the_commands_sent() {
let mut s = with_data_sets(&["Alpha", "Beta"]);
for (stop, rows) in [(true, 1), (false, 2)] {
let r = s.execute("action.batch", &json!({"source": "datasets", "stopOnError": stop, "commands": open_then_failing_align()})).unwrap();
assert_eq!(r["failed"], rows, "{r}");
for row in 0..rows {
assert_eq!(named_step(&r, row), "3 (`object.align`)", "{r}");
}
}
}
/// A recorded export that Override Export points into the destination folder keeps its place in
/// the `commands` sent when it fails.
#[test]
fn a_redirected_export_that_fails_keeps_its_place() {
let src = tmp("step-number-export-src");
write_svg(&src, "a.svg", "#f00");
let dest = tmp("step-number-export-out");
// A file where the export's folder goes, so the export cannot write its files.
std::fs::write(dest.join("a"), "taken").unwrap();
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [
{"command": "document.open", "params": {"path": "/no/such/file.svg"}},
{"command": "object.unlockAll", "params": {}},
{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}
]
}),
)
.unwrap();
assert_eq!(r["failed"], 1, "{r}");
assert_eq!(named_step(&r, 0), "3 (`document.exportForScreens`)", "{r}");
}
/// A source file that does not open, which the batch opens itself, gives the open's own error
/// with no step number.
#[test]
fn a_source_that_does_not_open_names_no_step() {
let src = tmp("step-number-bad-src");
std::fs::write(src.join("a.svg"), "not an svg").unwrap();
let mut s = Session::new();
let r = s.execute("action.batch", &json!({"folder": src.to_string_lossy(), "commands": open_then_failing_align()})).unwrap();
assert_eq!(r["failed"], 1, "{r}");
let e = r["processed"][0]["error"].as_str().unwrap_or_default();
assert!(!e.is_empty() && !e.contains("action step") && !e.contains("batch step"), "{r}");
}
/// A `prefs.import` step, which `command.batch` does not run, is refused before any file opens.
#[test]
fn a_prefs_import_step_is_refused_before_any_file_opens() {
let src = tmp("step-number-prefs-src");
write_svg(&src, "a.svg", "#f00");
let mut s = Session::new();
let p = json!({"folder": src.to_string_lossy(), "commands": [{"command": "object.unlockAll"}, {"command": "prefs.import", "params": {}}]});
let e = s.execute("action.batch", &p).unwrap_err().to_string();
assert!(e.contains("commands[1]: `prefs.import` runs on its own, not as a batch step"), "{e}");
assert!(s.active().is_none(), "no file was opened");
}
/// Only the error `command.batch` gives for a failed step is read as one.
#[test]
fn batch_step_failure_reads_only_a_step_error() {
assert_eq!(batch_step_failure("batch step 12 (`object.move`) failed: no selection"), Some((12, "object.move", "no selection")));
for e in [
"batch step (`object.move`) failed: no selection",
"batch step 99999999999999999999999 (`object.move`) failed: x",
"batch step 1 `object.move` failed: x",
"invalid parameters for `command.batch`: batches cannot nest",
"batch step 1",
] {
assert_eq!(batch_step_failure(e), None, "{e}");
}
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them",
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})",
has_selection,
export::export_selection
),
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. A switch (includeSubdirs, overrideOpen, overrideSave, overrideExport, stopOnError) that is not true or false, or a source, folder, destination, destinationFolder, datasetFileNaming or label that is not a string, is an error before any folder is made or file opened; null reads as left out. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest, and a prefs.import step is refused (commands[i], from 0) before any file is opened. A switch (includeSubdirs, overrideOpen, overrideSave, overrideExport, stopOnError) that is not true or false, or a source, folder, destination, destinationFolder, datasetFileNaming or label that is not a string, is an error before any folder is made or file opened; null reads as left out. A step that fails is named in the error by its place in commands, from 1, counting the Open steps the batch skips (action step 3 (`object.align`) failed: …); the batch's own opening of a file, choosing of a data set and destination save, export or close give their errors without a step number. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)",
always,
action_batch::action_batch
),
+14 -4
View File
@@ -295,8 +295,14 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C
/// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a
/// text export holds the type of the selected objects and not that of a clipping path around them.
fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = selected_artwork(s)?;
let doc = &s.doc()?.doc;
selection_doc(s.doc()?, cmd, text)
}
/// The document an Export Selection of `st` writes and its crop, as `document.exportSelection`
/// writes it ([`selection`]); `cmd` names the command in the error when nothing selected draws.
pub fn selection_doc(st: &DocState, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = artwork_of(st);
let doc = &st.doc;
let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?;
if text {
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
@@ -312,12 +318,16 @@ fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vector
/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected,
/// the instance is exported whole.
pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(artwork_of(s.doc()?))
}
/// [`selected_artwork`] of document `st`.
fn artwork_of(st: &DocState) -> Vec<NodeId> {
let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc));
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
ids.into_iter().filter(|id| !on_template(*id)).collect()
}
/// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): the bounds the renderer
File diff suppressed because one or more lines are too long
+16
View File
@@ -186,6 +186,22 @@ fn planar_tiffs_read_as_their_chunky_versions() {
}
}
/// A 16-bit CMYK TIFF with alpha places with its alpha rounded from 16 bits, planar as chunky.
#[test]
fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() {
let none = vec![0u16; 6];
let cyan = vec![129u16, 0, 300, 0, 128, 65535];
let alpha = vec![65535u16, 129, 386, 0, 256, 65535];
for extra in [1u16, 2] {
let channels = [cyan.clone(), none.clone(), none.clone(), none.clone(), alpha.clone()];
let chunky = kept(&format!("extra samples {extra}, chunky"), &hand_tiff(5, 16, &[extra], false, &channels));
let planar = kept(&format!("extra samples {extra}, planar"), &hand_tiff(5, 16, &[extra], true, &channels));
assert_eq!(planar, chunky, "extra samples {extra}: planar reads as chunky");
let alphas: Vec<u8> = chunky.1.iter().skip(3).step_by(4).copied().collect();
assert_eq!(alphas, [255, 1, 2, 0, 1, 255], "extra samples {extra}: the alpha, rounded from 16 bits");
}
}
/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows
/// a strip each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
+60 -2
View File
@@ -424,8 +424,8 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() {
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"
)
};
// Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a gradient fill,
// which isn't read, so it is left out rather than listed with no paint.
// Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a fill with a gradient
// the file doesn't define, which is left out rather than listed with no paint.
let one_paint = |name: &str, paint: &str| format!("/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n");
let styles = format!(
"%AI9_BeginArtStyles\n{}{}{}{}{}%AI9_EndArtStyles\n\
@@ -465,6 +465,64 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() {
assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3), ("Red outline", 4)], "{r}");
}
/// A `.ai` graphic style library's styles that paint with one of the file's gradients by name (a
/// fill style's, and the fill or stroke of a style of one paint) load with that gradient at the
/// style's angle, fitted to the bounds of each object the style is applied to; one naming a gradient
/// the file doesn't define is left out.
#[test]
fn graphic_styles_in_a_ai_library_that_paint_with_a_named_gradient_load_with_it() {
let gradient =
"%AI5_BeginGradient: (Sky)\n(Sky) 0 2 Bd\n[\n%_0 0 0 0 0 1 0 2 1 6 50 0 Bs\n%_0 0 0 0 1 1 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let band = "/KnownStyle :\n(Sky band) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n\
0 Bb\n2 (Sky) 0 0 45 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n";
let one_paint = |name: &str, paint: &str| format!("/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n");
let styles = format!(
"%AI9_BeginArtStyles\n{band}{}{}{}%AI9_EndArtStyles\n\
%AI9_BeginArtStyleList\n(Sky band)\n(Sky fill)\n(Sky outline)\n(Dusk fill)\n%AI9_EndArtStyleList\n",
one_paint("Sky fill", "0 O\n0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n"),
one_paint("Sky outline", "0 R\n0 J 0 j 2 w 10 M []0 d\n1 Bb\n2 (Sky) 0 0 90 100 1 0 0 1 0 0 1 Bg\n0 BB\n1 0 Xd\n"),
one_paint("Dusk fill", "0 O\n0 Bb\n2 (Dusk) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n")
);
// A palette with the gradient's swatch, so the document opened from the file shows its gradient.
let palette = INKS_PALETTE.replace("0 0 Pb\n", "0 0 Pb\nBb\n2 (Sky) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Sky)\nPc\n");
let data = ai::editing_data(200.0, 100.0, &format!("{gradient}{palette}{styles}"));
let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
let mut s = Session::new();
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "skies.ai"});
let r = s.execute("graphicStyle.loadLibrary", &p).unwrap();
let library = r["library"].clone();
let lib = s.execute("graphicStyle.library", &json!({"library": library})).unwrap();
let names: Vec<&str> = lib["styles"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Sky band", "Sky fill", "Sky outline"], "{lib}");
// The file's gradient, as the document opened from it has its swatch.
open(&mut s, "skies.ai", &file, json!({})).unwrap();
let sky = match s.doc().unwrap().doc.swatch("Sky").map(|w| w.paint.clone()) {
Some(vectorcraft_color::Paint::Gradient(g)) => g.gradient,
other => panic!("{other:?}"),
};
// Applied to a small and a large rectangle, each gradient is fitted to the rectangle it paints.
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
for (style, (x, size)) in [("Sky band", (10, 20)), ("Sky fill", (40, 200)), ("Sky outline", (10, 120))] {
let rect = json!({"x": x, "y": x, "width": size, "height": size / 2});
let id = NodeId(s.execute("shape.rectangle", &rect).unwrap()["id"].as_u64().unwrap());
let r = s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": [style], "apply": true, "ids": [id.0]})).unwrap();
assert_eq!(r["added"], json!([style]), "{r}");
let look = &s.doc().unwrap().doc.node(id).unwrap().appearance;
let (paint, angle, stroked) = match style {
"Sky outline" => (look.stroke().map(|st| &st.paint), 90.0, true),
"Sky band" => (look.fill().map(|f| &f.paint), 45.0, false),
_ => (look.fill().map(|f| &f.paint), 0.0, false),
};
let Some(vectorcraft_color::Paint::Gradient(g)) = paint else { panic!("{style}: {look:?}") };
assert_eq!((&g.gradient, g.geom, g.angle), (&sky, None, angle), "{style}");
assert_eq!(look.stroke().is_some(), stroked, "{style}: {look:?}");
if stroked {
assert!(look.fill().is_none() && look.stroke().is_some_and(|st| st.width == 2.0), "{look:?}");
}
}
}
/// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other
/// Library… in the Symbols panel lists them in the list's order.
#[test]
+102 -10
View File
@@ -184,15 +184,17 @@ impl Sets {
/// boxes it overlaps, not every open one (many objects stacked in a column, sharing their
/// horizontal extent, would otherwise compare every pair). An open box inside a later box of
/// its group, ending no further right, is closed early, and a box inside an open box of its group,
/// ending no further right, isn't opened: whatever overlaps it overlaps that box. Fails only when
/// cancelled ([`vectorcraft_doc::cancel`]).
/// ending no further right, isn't opened: whatever overlaps it overlaps that box. An open box that
/// a later box of its group overlaps, starting lower and ending no further right or lower, keeps
/// only its part above the later box's top, so boxes staggered right and down each meet only the
/// one before. Fails only when cancelled ([`vectorcraft_doc::cancel`]).
pub(super) fn overlapping(bounds: &[Option<Rect>]) -> Result<Vec<Vec<usize>>, Cancelled> {
Ok(sweep(bounds, None)?.groups())
}
/// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is
/// see-through, and an open box is closed early, or a box isn't opened, only inside a see-through
/// box. The open
/// see-through, an open box is closed early, or a box isn't opened, only inside a see-through box,
/// and an open box is cut short only by a see-through box that is opened. The open
/// see-through boxes and the open boxes without transparency are then in trees of their own, and
/// a box without transparency meets only the see-through ones.
fn sweep(bounds: &[Option<Rect>], see_through: Option<&[bool]>) -> Result<Sets, Cancelled> {
@@ -285,12 +287,20 @@ fn sweep(bounds: &[Option<Rect>], see_through: Option<&[bool]>) -> Result<Sets,
see_through.is_none_or(|s| s[boxes[o].0]) && b.x1 <= ob.x1 && ob.y0 <= b.y0 && b.y1 <= ob.y1
});
// Whatever overlaps a box inside this one overlaps this one, and joins it when this one is
// see-through.
// see-through. Likewise, whatever later overlaps the part of an open box at or below this
// one's top, when that box ends no further right or lower than this one, overlaps this
// one: the open box then keeps only the part above this one's top.
let cut = b.y0.next_down();
for &o in &met {
sets.join(boxes[k].0, boxes[o].0);
let ob = boxes[o].1;
if shadows && Some(o) != holder && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 {
if !shadows || Some(o) == holder || b.x1 < ob.x1 || b.y1 < ob.y1 {
continue;
}
if b.y0 <= ob.y0 {
set(&mut low[tree(o)], leaf[o], f64::NEG_INFINITY);
} else if holder.is_none() && cut < low[tree(o)][leaf[o] + size] {
set(&mut low[tree(o)], leaf[o], cut);
}
}
if holder.is_none() {
@@ -428,8 +438,10 @@ mod tests {
every_pair_joining(bounds, |_, _| true)
}
/// The groups of `bounds` comparing every pair, joining overlapping pairs that `joins`.
/// The groups of `bounds` comparing every pair, joining overlapping pairs that `joins`. A box
/// that isn't finite ([`finite`]) overlaps none.
fn every_pair_joining(bounds: &[Option<Rect>], joins: impl Fn(usize, usize) -> bool) -> Vec<Vec<usize>> {
let bounds: Vec<Option<Rect>> = bounds.iter().map(|b| b.filter(finite)).collect();
let n = bounds.len();
let mut group: Vec<usize> = (0..n).collect();
let hit = |a: &Rect, b: &Rect| a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1;
@@ -731,9 +743,9 @@ mod tests {
];
let see = [false, true, true, false];
let mut sets = sweep(&bounds, Some(&see)).unwrap();
// The second box meets the first; the third meets the second; the fourth meets the second
// and the third.
assert_eq!(sets.meets, 4);
// The second box meets the first; the third meets the second, which keeps only its part
// above the third's top; the fourth meets the third.
assert_eq!(sets.meets, 3);
assert_eq!(sets.groups(), vec![vec![0, 1, 2, 3]]);
// Opaque boxes alone meet nothing.
let mut sets = sweep(&bounds, Some(&[false; 4])).unwrap();
@@ -741,6 +753,86 @@ mod tests {
assert_eq!(sets.groups().len(), 4);
}
/// `n` boxes of one size, each a little right of and below the one before, all overlapping and
/// none inside another.
fn staggered(n: usize) -> Vec<Option<Rect>> {
let w = 2.0 * n as f64;
(0..n).map(|i| Some(Rect::new(i as f64, i as f64, w + i as f64, w + i as f64))).collect()
}
/// Each box of a stagger right and down meets only the box before it, which keeps only its
/// part above the new box's top, not every box still open.
#[test]
fn staggered_boxes_meet_only_the_box_before() {
let n = 512;
let bounds = staggered(n);
let mut sets = sweep(&bounds, None).unwrap();
assert_eq!(sets.meets, n - 1);
assert_eq!(sets.groups(), vec![(0..n).collect::<Vec<_>>()]);
// See-through boxes do the same with transparency.
let mut sets = sweep(&bounds, Some(&vec![true; n])).unwrap();
assert_eq!(sets.meets, n - 1);
assert_eq!(sets.groups(), vec![(0..n).collect::<Vec<_>>()]);
}
/// An open box keeps the part a later box would meet without meeting the box that cut it short:
/// a part lower than that box, a part further right, or any part when that box is opaque.
#[test]
fn a_box_is_cut_short_only_where_the_later_box_covers_it() {
let k = Rect::new(5.0, 5.0, 15.0, 15.0);
// Lower than `k`: the last box overlaps only the bottom of the first.
let lower = [Some(Rect::new(0.0, 0.0, 10.0, 20.0)), Some(k), Some(Rect::new(8.0, 17.0, 9.0, 25.0))];
assert_eq!(overlapping(&lower).unwrap(), vec![vec![0, 1, 2]]);
// Further right than `k`: the last box starts right of it and overlaps the first.
let right = [Some(Rect::new(0.0, 0.0, 20.0, 10.0)), Some(k), Some(Rect::new(17.0, 7.0, 25.0, 9.0))];
assert_eq!(overlapping(&right).unwrap(), vec![vec![0, 1, 2]]);
// An opaque `k` doesn't join the opaque last box, which joins the see-through first one.
let opaque = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(k), Some(Rect::new(6.0, 8.0, 7.0, 20.0))];
let mut sets = sweep(&opaque, Some(&[true, false, false])).unwrap();
assert_eq!(sets.groups(), vec![vec![0, 1, 2]]);
// A see-through `k` held by a see-through box around it isn't opened and cuts nothing short.
let held = [Some(Rect::new(0.0, 2.0, 30.0, 30.0)), Some(Rect::new(1.0, 0.0, 10.0, 10.0)), Some(k), Some(Rect::new(6.0, 8.0, 7.0, 20.0))];
let see = [true, false, true, false];
let mut sets = sweep(&held, Some(&see)).unwrap();
assert_eq!(sets.groups(), vec![vec![0, 1, 2, 3]]);
assert_eq!(sets.groups(), every_pair_joining(&held, |i, j| see[i] || see[j]));
}
/// Staggers in all four directions, of mixed sizes (empty ones included) with gaps and shared
/// edges, in either order, with signed zeros and boxes that aren't finite, some see-through,
/// group as comparing every pair groups them.
#[test]
fn staggered_boxes_group_as_comparing_every_pair() {
let mut seed = 0x3c6e_f372_fe94_f82bu64;
let mut next = |m: f64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 1000) as f64 / 1000.0 * m
};
for round in 0..48 {
let n = 8 + round * 2;
let (sx, sy) = (if round % 2 == 0 { 1.0 } else { -1.0 }, if round / 2 % 2 == 0 { 1.0 } else { -1.0 });
let mut bounds: Vec<Option<Rect>> = (0..n)
.map(|i| {
let step = next(4.0).floor();
let (x, y) = (sx * (i as f64 * 2.0 + step), sy * (i as f64 * 2.0 + next(4.0).floor()));
let (w, h) = (next(8.0).floor() * 2.0, next(8.0).floor() * 2.0);
Some(Rect::new(x, y, x + w, y + h))
})
.collect();
bounds[0] = Some(Rect::new(-0.0, -0.0, 0.0, 6.0));
bounds[n / 2] = Some(Rect::new(0.0, sy * 6.0, f64::INFINITY, sy * 6.0 + 4.0));
if round / 4 % 2 == 1 {
bounds.reverse();
}
assert_eq!(overlapping(&bounds).unwrap(), every_pair(&bounds), "round {round}");
let see: Vec<bool> = (0..n).map(|_| next(1.0) < 0.5).collect();
let mut sets = sweep(&bounds, Some(&see)).unwrap();
assert_eq!(sets.groups(), every_pair_joining(&bounds, |i, j| see[i] || see[j]), "round {round}, with transparency");
}
}
/// An opaque box between a member and its group's top that paints over the member joins, and
/// so on along a chain; one under the member stays, but for one within the fringe.
#[test]
+1 -1
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {index}",
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind; the points on a label's characters are not stored → {index}",
has_doc,
add_point
),
+2 -2
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without its placement, fitted to each label",
has_doc,
edit_gradient
),
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient Vector",
[],
None,
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label",
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without the placement this command sets, fitted to each label",
has_doc,
set_gradient_geom
),
+4 -4
View File
@@ -45,10 +45,10 @@ use generate::generate;
#[cfg(test)]
use series::default_series_color;
pub(crate) use series::{
LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles,
capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art,
label_object_opacity_differs, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs,
shown_paint,
LabelTextChange, capture_label_anti_alias, capture_label_changes, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows,
capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art,
label_object_opacity_differs, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_marks_in,
series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
+2 -3
View File
@@ -6,7 +6,7 @@ use super::labels::{
recolour_text, runs, shadow_of,
};
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
use super::series::{series_marks, with_series_gradients, with_series_opacity};
use super::series::{picks_legend_fill, series_marks, with_series_gradients, with_series_opacity};
use super::{
Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes,
};
@@ -397,8 +397,7 @@ impl<'a> Plot<'a> {
let at = ring.centre + dir(mid) * (ring.hole + (ring.rad - ring.hole) * 0.62);
let t = self.b.label(Role::Legend, Point::new(at.x, at.y + self.ls * 0.35), &label, Justify::Center, true);
// White on a dark wedge, unless the legend labels were given a colour of their own.
let own_colour = self.g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK));
self.wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t });
self.wedge_labels.push(if dark && picks_legend_fill(self.g) { recolour_text(&t, Color::WHITE) } else { t });
}
/// The pie's category name.
+155 -10
View File
@@ -626,20 +626,27 @@ pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], metho
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
let mut changed = false;
for (graph, role) in label_kinds(doc, ids) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let mut style = label_style(&spec.label_styles, role);
change(&mut style);
let slot = match role {
Role::Value => &mut spec.label_styles.values,
Role::Category => &mut spec.label_styles.categories,
Role::Legend => &mut spec.label_styles.legend,
};
changed |= slot.as_ref() != Some(&style);
*slot = Some(style);
changed |= store_label_style(doc, graph, role, change);
}
changed
}
/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). Returns whether
/// the kept style changed.
fn store_label_style(doc: &mut Document, graph: NodeId, role: Role, change: &dyn Fn(&mut CharStyle)) -> bool {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { return false };
let mut style = label_style(&spec.label_styles, role);
change(&mut style);
let slot = match role {
Role::Value => &mut spec.label_styles.values,
Role::Category => &mut spec.label_styles.categories,
Role::Legend => &mut spec.label_styles.legend,
};
let changed = slot.as_ref() != Some(&style);
*slot = Some(style);
changed
}
/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character
/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes.
@@ -807,6 +814,144 @@ pub(crate) fn capture_label_text(
Ok(changed)
}
/// The character fill and stroke attributes a kind of graph label keeps when a recolor, or a stroke or gradient edit
/// aimed at the characters, changes them on its labels ([`capture_label_changes`]). Each entry copies one attribute
/// from the first style to the second and returns whether they differed.
const RUN_PAINT: [fn(&CharStyle, &mut CharStyle) -> bool; 8] = [
|a, b| keep(&a.fill, &mut b.fill),
|a, b| keep(&a.stroke, &mut b.stroke),
|a, b| keep(&a.stroke_width, &mut b.stroke_width),
|a, b| keep(&a.stroke_cap, &mut b.stroke_cap),
|a, b| keep(&a.stroke_join, &mut b.stroke_join),
|a, b| keep(&a.stroke_miter_limit, &mut b.stroke_miter_limit),
|a, b| keep(&a.stroke_dash, &mut b.stroke_dash),
|a, b| keep(&a.stroke_align, &mut b.stroke_align),
];
/// Copy `from` to `to`. Returns whether they differed.
fn keep<T: Clone + PartialEq>(from: &T, to: &mut T) -> bool {
let differs = from != to;
if differs {
to.clone_from(from);
}
differs
}
/// The [`RUN_PAINT`] attributes an edit changed on the characters of graph label `now` (`was`: the label before the
/// edit), as the label's style and the indexes of the changed entries. An attribute counts only when every character
/// of the label shows it alike, and the fill only when `fill` is set. A placed gradient is kept without its placement,
/// so it fits each label it lands on. The stroke weight and dashes are kept as the label shows them: the graph draws its
/// labels again without the label's own scale.
fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Vec<usize>)> {
let (NodeKind::Text(was), NodeKind::Text(now)) = (&was.kind, &now.kind) else { return None };
let first_now = &now.runs.first()?.style;
// Changed: some character showed something else before the edit, whichever run it is in.
let changed: Vec<usize> = RUN_PAINT
.iter()
.enumerate()
.filter(|(i, copy)| {
(fill || *i != 0)
&& now.runs.iter().all(|r| !copy(first_now, &mut r.style.clone()))
&& was.runs.iter().any(|r| copy(first_now, &mut r.style.clone()))
})
.map(|(i, _)| i)
.collect();
if changed.is_empty() {
return None;
}
let unplaced = crate::cmd::gradient::unplaced;
let mut style = first_now.clone();
(style.fill, style.stroke) = (unplaced(&style.fill), unplaced(&style.stroke));
// Character strokes are drawn in text space, scaled by the type's transform.
if let Some(k) = vectorcraft_doc::Scaling::factor(now.xf).filter(|k| k.is_finite() && *k > 1e-12) {
let mut st = style.stroke_layer();
crate::inspect::scale_layer(&mut st, k);
style.set_stroke_layer(&st);
}
Some((style, changed))
}
/// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with
/// Legends in Wedges whose legend labels keep no fill of their own?
pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool {
g.kind == GraphKind::Pie
&& g.legend
&& g.pie_legend_in_wedges
&& !g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK))
}
/// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them.
pub(crate) fn labels_in(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut texts = Vec::new();
for id in roots {
let Some(root) = doc.node(*id) else { continue };
root.walk(&mut |n| {
if matches!(n.kind, NodeKind::Text(_)) {
texts.push(n.id);
}
});
}
// Graph labels are type: without type among `roots`, the graph's labels are not looked up.
if texts.is_empty() {
return texts;
}
let labels = graph_labels(doc);
texts.retain(|id| labels.contains_key(id));
// Nested roots (a graph and one of its labels) reach a label twice.
texts.sort_unstable();
texts.dedup();
texts
}
/// Keep for every label of their kind what an edit changed on graph labels `ids` (`before`: the document before the
/// edit): with `rows`, the object rows ([`capture_label_rows`]) of the labels whose rows changed, and the character fill
/// and stroke attributes ([`RUN_PAINT`]) that changed on all of a label's characters, on the kind's kept style. The
/// frontmost label of a kind sets what that kind keeps, whatever order `ids` are in. On a graph that colors its legend
/// labels itself ([`picks_legend_fill`]), their fill is kept only when the legend labels among `ids` end up with one
/// fill. Called inside the same edit, so they undo together. Returns whether what a graph keeps changed.
pub(crate) fn capture_label_changes(before: &Document, doc: &mut Document, ids: &[NodeId], rows: bool) -> bool {
if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) {
return false;
}
let labels = graph_labels(doc);
let mut picked: Vec<NodeId> = ids.iter().copied().filter(|id| labels.contains_key(id)).collect();
// A later label's capture replaces an earlier one's: take them in paint order, each once.
picked.sort_by_cached_key(|id| doc.index_path(*id));
picked.dedup();
let picks_fill = |graph: NodeId| doc.node(graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(picks_legend_fill);
// On a pie that colors its legend labels black or white by their wedge, a recolor of the whole pie flips them both
// ways: their fill is kept only when every legend label the edit reached ends up with the same one.
let mut wedge_fills: HashMap<NodeId, Vec<Paint>> = HashMap::new();
for id in &picked {
let (Some(&(graph, Role::Legend)), Some(NodeKind::Text(t))) = (labels.get(id), doc.node(*id).map(|n| &n.kind)) else { continue };
if picks_fill(graph) {
wedge_fills.entry(graph).or_default().extend(t.runs.iter().map(|r| r.style.fill.clone()));
}
}
let one_fill = |graph: NodeId| wedge_fills.get(&graph).is_none_or(|f| f.windows(2).all(|w| w[0] == w[1]));
let mut changed_rows = Vec::new();
let mut characters = Vec::new();
for (id, kind) in picked.iter().filter_map(|id| Some((*id, *labels.get(id)?))) {
let (Some(was), Some(now)) = (before.node(id), doc.node(id)) else { continue };
if rows && label_rows(was) != label_rows(now) {
changed_rows.push(id);
}
let fill = kind.1 != Role::Legend || one_fill(kind.0);
characters.extend(run_paint_change(was, now, fill).map(|change| (kind, change)));
}
let mut changed = capture_label_rows(doc, &changed_rows);
for ((graph, role), (style, entries)) in &characters {
changed |= store_label_style(doc, *graph, *role, &|st| {
for i in entries {
if let Some(copy) = RUN_PAINT.get(*i) {
copy(style, st);
}
}
});
}
changed
}
/// The graphs and kinds of label among `ids`, each once.
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
if ids.is_empty() {
+324
View File
@@ -508,6 +508,89 @@ fn paint_targets_leave_a_graph_designs_copy_out() {
assert_eq!(super::paint_targets(d, &[copy, art, mark]), [mark]);
}
/// The art of a marker design in series 1 of a line graph: the design's copy and a path inside it.
fn marker_design_art() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Pill"})).unwrap();
let marker = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].clone();
assert!(matches!(marker.kind, NodeKind::Group { .. }), "the design's art");
let mut art = vec![];
marker.walk(&mut |m| {
if matches!(m.kind, NodeKind::Path { .. }) {
art.push(m.id);
}
});
(s, marker.id, art[0])
}
/// The ids `graphicStyle.list` reports as linked to style `name`.
fn linked_to(s: &mut Session, name: &str) -> Vec<u64> {
let list = s.execute("graphicStyle.list", &json!({})).unwrap();
let style = list["styles"].as_array().unwrap().iter().find(|g| g["name"] == name).unwrap().clone();
let mut ids: Vec<u64> = style["linked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
ids.sort_unstable();
ids
}
/// Graphic styles and the art of a column or marker design in a series (the design's copy and a path inside it):
/// `graphicStyle.new` makes a style from the art without linking it; `graphicStyle.redefine` leaves the art's look as
/// it was and unlinks it, whether the art is linked (as in a document saved before) or the source, while the other
/// linked objects follow the new look.
#[test]
fn graphic_styles_leave_a_graph_designs_art_unlinked() {
let make = |marker: bool| {
if marker {
marker_design_art()
} else {
let (s, _, copy, art, _) = design_art();
(s, copy, art)
}
};
let rows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.clone();
let link = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().extras().graphic_style;
let link_directly = |s: &mut Session, ids: &[NodeId], style: u32| {
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
for id in ids {
d.node_mut(*id).unwrap().extras_mut().graphic_style = Some(style);
}
};
let rect = |s: &mut Session| {
NodeId(s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap())
};
for marker in [false, true] {
for source in [0, 1] {
let at = format!("marker {marker}, source {source}");
// New: the style is made from the art, which stays unlinked.
let (mut s, copy, art) = make(marker);
let source = [copy, art][source];
s.execute("graphicStyle.new", &json!({"id": source.0, "name": "Art"})).unwrap();
assert_eq!(link(&s, source), None, "{at}");
assert_eq!(linked_to(&mut s, "Art"), Vec::<u64>::new(), "{at}");
// Redefine from another object: linked art keeps its look and is unlinked; a linked rectangle follows.
let follower = rect(&mut s);
s.execute("graphicStyle.apply", &json!({"name": "Art", "ids": [follower.0]})).unwrap();
let style = link(&s, follower).unwrap();
link_directly(&mut s, &[copy, art], style);
let (copy_was, art_was) = (rows(&s, copy), rows(&s, art));
let (blue_rect, blue) = blue_style(&mut s, "Blue", false);
s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": blue_rect.0})).unwrap();
assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was.clone(), art_was.clone()), "{at}");
assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}");
assert_eq!(rows(&s, follower).fill_paint(), blue, "{at}");
assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}");
// Redefine from the art itself: the art is unlinked and the linked rectangle takes the art's look.
link_directly(&mut s, &[copy, art], style);
s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": source.0})).unwrap();
assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was, art_was), "{at}");
assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}");
assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}");
}
}
}
/// The art of a marker design in a series is refused too.
#[test]
fn effect_commands_refuse_a_marker_designs_art() {
@@ -5991,6 +6074,247 @@ fn a_graphic_style_on_a_label_is_kept_for_its_kind() {
assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}");
}
/// The character style of the first run of label `text` in group `grp`.
fn label_run_style(s: &Session, id: NodeId, grp: &str, text: &str) -> CharStyle {
let NodeKind::Text(t) = label_node(s, id, grp, text).kind else { panic!("{text} is not type") };
t.runs[0].style.clone()
}
/// A recolor of the whole graph (Invert or Complement on the proxy, an Edit Colors command, a color filter or Recolor
/// Artwork) is kept for the characters and the rows of every kind of label when the graph is drawn again, and undo takes
/// it back with its edit.
#[test]
fn recoloring_a_graph_keeps_its_labels_paint_for_every_kind() {
let cases = [
("paint.invert", json!({})),
("paint.complement", json!({})),
("edit.colors.invert", json!({})),
("edit.colors.toGrayscale", json!({})),
("addon.color.filter", json!({"effect": "addon.color.invert"})),
("recolor.apply", json!({"map": {"#3366cc": "#00ff00", "#ff8000": "#8000ff"}})),
];
every_case(cases, |(cmd, params)| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
// Characters of every label in a color that every case changes, and an orange fill row on the category labels.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap();
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff8000"})).unwrap();
let was = spec(&s, id).label_styles;
let mut p = params.clone();
if *cmd == "recolor.apply" {
// Recolor Artwork works on the selection.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
} else {
p["ids"] = json!([id.0]);
}
s.execute(cmd, &p).unwrap();
let hex = |p: &Paint| p.color().map(|c| c.to_hex()).unwrap_or_default();
let characters = hex(&label_run_style(&s, id, "Axes", "4").fill);
assert_ne!(characters, "#3366cc", "{cmd} recolors the characters");
let row = label_node(&s, id, "Axes", "Q2").appearance.items[0].paint().clone();
assert_ne!(hex(&row), "#ff8000", "{cmd} recolors the row");
let looks = label_looks(&mut s, id);
for t in ["0", "Q1", "2024"] {
assert!(looks.iter().any(|(x, _, fill)| x == t && *fill == characters), "{cmd}: {t}: {looks:?}");
}
let rows = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&rows, "Q1").0.first().map(|r| r.1.clone()), Some(hex(&row)), "{cmd}: {rows:?}");
// Undo takes back the recolor and what the graph keeps (the two redraws are undone first).
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(spec(&s, id).label_styles, was, "{cmd}");
});
}
/// A recolor of one label changes what the graph keeps for that kind of label only.
#[test]
fn recoloring_one_label_keeps_its_paint_for_its_kind_only() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("edit.colors.invert", &json!({"ids": [v.0]})).unwrap();
let looks = label_looks(&mut s, id);
assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ffffff"), "{looks:?}");
assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#000000"), "{looks:?}");
assert!(looks.iter().any(|(t, _, fill)| t == "2024" && fill == "#000000"), "{looks:?}");
let g = spec(&s, id).label_styles;
assert!(g.categories.is_none() && g.legend.is_none(), "{g:?}");
}
/// A recolor that leaves a label's first characters as they were and gives the rest the same fill is kept for its kind.
#[test]
fn a_recolor_that_evens_out_a_labels_characters_is_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let q2 = text_id(&s, id, "Axes", "Q2");
s.select_tool("type", crate::ViewInfo::default()).unwrap();
for (start, end, hex) in [(0, 1, "#ff0000"), (1, 2, "#0000ff")] {
s.set_tool_option("textSelection", &json!({"id": q2.0, "start": start, "end": end}));
s.execute("paint.setFill", &json!({"color": hex})).unwrap();
}
s.select_tool("selection", crate::ViewInfo::default()).unwrap();
assert_eq!(spec(&s, id).label_styles.categories, None, "part of a label's text is not kept");
s.execute("select.set", &json!({"ids": [q2.0]})).unwrap();
s.execute("recolor.apply", &json!({"map": {"#0000ff": "#ff0000"}})).unwrap();
let looks = label_looks(&mut s, id);
assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#ff0000"), "{looks:?}");
assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#000000"), "{looks:?}");
}
/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` and placed with
/// `paint.setGradientGeom` on a label's characters are kept for every label of its kind, the gradient without its
/// placement.
#[test]
fn stroke_and_gradient_edits_on_a_labels_characters_are_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let category = label_run_style(&s, id, "Axes", "Q1");
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3, "cap": "round"})).unwrap();
s.execute("paint.editGradient", &json!({"ids": [v.0], "kind": "linear", "stroke": false})).unwrap();
let placement = json!({"ids": [v.0], "stroke": false, "start": [100, 100], "end": [200, 100], "aspect": 50});
s.execute("paint.setGradientGeom", &placement).unwrap();
let fill = label_run_style(&s, id, "Axes", "4").fill;
assert!(matches!(&fill, Paint::Gradient(g) if g.geom.is_some()), "the edited label's gradient is placed: {fill:?}");
label_looks(&mut s, id);
let st = label_run_style(&s, id, "Axes", "0");
assert_eq!((st.stroke_width, st.stroke_cap), (3.0, vectorcraft_doc::LineCap::Round));
assert!(matches!(&st.fill, Paint::Gradient(g) if g.geom.is_none()), "{:?}", st.fill);
assert_eq!(label_run_style(&s, id, "Axes", "Q1"), category, "the category labels are left as they were");
}
/// `stroke.set`, `stroke.weightUp` and `stroke.weightDown` given the whole graph are kept for every kind of label.
#[test]
fn stroke_weights_set_on_the_whole_graph_are_kept_for_its_labels() {
let cases = [("stroke.set", json!({"weight": 2}), 2.0), ("stroke.weightUp", json!({}), 2.0), ("stroke.weightDown", json!({}), 0.75)];
every_case(cases, |(cmd, params, weight)| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("paint.setStroke", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap();
let mut p = params.clone();
p["ids"] = json!([id.0]);
s.execute(cmd, &p).unwrap();
assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, *weight, "{cmd}");
label_looks(&mut s, id);
for (grp, t) in [("Axes", "0"), ("Axes", "Q1"), ("Legend", "2024")] {
assert_eq!(label_run_style(&s, id, grp, t).stroke_width, *weight, "{cmd}: {t}");
}
});
}
/// A stroke weight set on a scaled label is kept as the label shows it, since the graph draws its labels unscaled.
#[test]
fn a_stroke_weight_on_a_scaled_label_is_kept_as_it_shows() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3})).unwrap();
assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, 1.5, "drawn at twice its size");
label_looks(&mut s, id);
assert_eq!(label_run_style(&s, id, "Axes", "0").stroke_width, 3.0);
}
/// When the characters of two labels of one kind are recolored differently, the frontmost one sets what the kind
/// keeps, whatever order `ids` are in.
#[test]
fn the_frontmost_label_of_a_kind_sets_what_its_recolor_keeps() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
// Legend labels: the axes tell value and category labels apart by their order, so these are moved instead.
let (a, b) = (text_id(&s, id, "Legend", "2024"), text_id(&s, id, "Legend", "2025"));
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#0000ff"})).unwrap();
// The label made first goes in front, so neither the order of `ids` nor the order the labels were made in decides.
let (front, back) = if a < b { (a, b) } else { (b, a) };
s.execute("select.set", &json!({"ids": [front.0]})).unwrap();
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
let path = |n: NodeId| s.doc().unwrap().doc.index_path(n);
assert!(path(front) > path(back), "{:?} {:?}", path(front), path(back));
let front_fill = if front == a { "#00ffff" } else { "#ffff00" };
s.execute("edit.colors.invert", &json!({"ids": [front.0, back.0]})).unwrap();
let kept = spec(&s, id).label_styles.legend.unwrap().fill;
assert_eq!(kept.color().map(|c| c.to_hex()).as_deref(), Some(front_fill));
}
/// Apply Nearest Swatch and the ink limit of Print Ink Fixes on a label are kept for its kind.
#[test]
fn nearest_swatch_and_ink_limit_on_a_label_are_kept_for_its_kind() {
let cases = [
("addon.color.applyNearestSwatch", json!({"library": "web-safe-216"})),
("addon.color.printFixes", json!({"scope": "selection", "inkLimit": 200})),
];
every_case(cases, |(cmd, params)| {
let mut s = Session::new();
// Print Ink Fixes works on CMYK documents.
s.execute("file.new", &json!({"width": 800, "height": 800, "colorMode": "cmyk"})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setFill", &json!({"ids": [v.0], "color": {"c": 90, "m": 80, "y": 70, "k": 60}})).unwrap();
let was = label_run_style(&s, id, "Axes", "4").fill;
let mut p = params.clone();
p["ids"] = json!([v.0]);
s.execute(cmd, &p).unwrap();
let now = label_run_style(&s, id, "Axes", "4").fill;
assert_ne!(now, was, "{cmd} changes the fill");
label_looks(&mut s, id);
assert_eq!(label_run_style(&s, id, "Axes", "0").fill, now, "{cmd}");
});
}
/// Swap Fill and Stroke and Default Fill and Stroke on a label are kept for its kind.
#[test]
fn swap_and_default_fill_and_stroke_on_a_label_are_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap();
s.execute("paint.swap", &json!({"ids": [v.0]})).unwrap();
let swapped = label_run_style(&s, id, "Axes", "4");
label_looks(&mut s, id);
let st = label_run_style(&s, id, "Axes", "0");
assert_eq!((&st.fill, &st.stroke), (&swapped.fill, &swapped.stroke));
assert_eq!(st.fill.color().map(|c| c.to_hex()).as_deref(), Some("#ff0000"));
s.execute("paint.default", &json!({"ids": [text_id(&s, id, "Axes", "4").0]})).unwrap();
label_looks(&mut s, id);
let st = label_run_style(&s, id, "Axes", "0");
assert_eq!((st.fill.color().map(|c| c.to_hex()).as_deref(), st.stroke.is_none()), (Some("#000000"), true));
}
/// On a pie with Legends in Wedges, a recolor of the whole graph leaves the legend labels to the graph's black or white.
#[test]
fn a_recolor_leaves_wedge_legend_labels_their_contrast() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nP1,1,2");
s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap();
s.execute("edit.colors.invert", &json!({"ids": [id.0]})).unwrap();
assert_eq!(spec(&s, id).label_styles.legend, None);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap();
// The first series' black wedge is white now, so its label is black.
let NodeKind::Text(t) = label_node(&s, id, "Legend", "a").kind else { panic!("a is not type") };
assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()).as_deref(), Some("#000000"));
// A recolor of one legend label gives the kind that label's fill.
s.execute("edit.colors.invert", &json!({"ids": [text_id(&s, id, "Legend", "a").0]})).unwrap();
let kept = spec(&s, id).label_styles.legend.map(|st| st.fill);
assert_eq!(kept.and_then(|f| f.color()).map(|c| c.to_hex()).as_deref(), Some("#ffffff"));
}
#[test]
fn pie_names_and_wedge_labels_keep_their_style() {
let mut s = Session::new();
+8 -22
View File
@@ -470,8 +470,8 @@ impl Library {
}
}
/// Make `g` a library gradient: no placement, freeform points or swatch links (a linked stop keeps
/// the color it shows), the angle its placement showed (a finite one), and stops in order with
/// Make `g` a library gradient: no placement, freeform points, swatch links or process plates (a
/// linked stop keeps the color it shows), the angle its placement showed (a finite one), and stops in order with
/// offsets, opacities and midpoints in range. False when it has fewer than 2 or more than
/// [`MAX_STOPS`] stops.
fn clean_gradient(g: &mut GradientPaint) -> bool {
@@ -490,8 +490,8 @@ fn clean_gradient(g: &mut GradientPaint) -> bool {
st.offset = within(st.offset, 0.0, 1.0, 0.0);
st.opacity = within(st.opacity, 0.0, 1.0, 1.0);
st.midpoint = within(st.midpoint, 0.13, 0.87, 0.5);
st.swatch = None;
st.tint = 1.0;
let color = st.color;
st.set_color(color, None);
}
g.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
true
@@ -1488,29 +1488,15 @@ fn add_to_char_styles(s: &mut Session, p: &Value) -> Result<Value> {
/// Unlink a library character style's `fill` and `stroke` colors (solid, and each gradient stop)
/// from swatches that are not global or spot swatches of `doc`: an unlinked color keeps the color
/// it shows at full tint, as [`clean_gradient`] keeps a library gradient's stops. A link to a
/// it shows at full tint, and an unlinked stop has no process plates, as [`clean_gradient`] keeps
/// a library gradient's stops. A link to a
/// global or spot swatch `doc` has stays, and takes that swatch's color.
fn unlink_missing_swatches(doc: &vectorcraft_doc::Document, attrs: &mut Map<String, Value>) {
let keep = |name: &str| doc.global_color(name).is_some();
for key in ["fill", "stroke"] {
let Some(mut paint) = attrs.get(key).and_then(|v| serde_json::from_value::<Paint>(v.clone()).ok()) else { continue };
let mut changed = false;
let mut unlink = |swatch: &mut Option<String>, tint: &mut f32| {
if swatch.as_deref().is_some_and(|n| !keep(n)) {
*swatch = None;
*tint = 1.0;
changed = true;
}
};
match &mut paint {
Paint::Solid { swatch, tint, .. } => unlink(swatch, tint),
Paint::Gradient(g) => {
for st in &mut g.gradient.stops {
unlink(&mut st.swatch, &mut st.tint);
}
}
_ => {}
}
// `map_links` gives an unlinked color full tint, and an unlinked stop no process plates.
let changed = paint.map_links(&mut |_, swatch, _| swatch.take_if(|n| !keep(n.as_str())).is_some());
if changed && let Ok(v) = serde_json::to_value(&paint) {
attrs.insert(key.to_string(), v);
}
+3 -2
View File
@@ -144,9 +144,10 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(_) => {
// Images and placed documents in opacity-mask art and in the art of symbols, graph
// designs and patterns are named by their ids too (`links.list`).
// designs, patterns and stored brushes are named by their ids too (`links.list`).
let ids = ids_param(p, "ids", C)?.unwrap_or_default();
let kind = |id: &NodeId| st.doc.stored_node(*id).map(|(n, _)| &n.kind);
let doc = super::links::with_brush_art(&st.doc);
let kind = |id: &NodeId| doc.stored_node(*id).map(|(n, _)| &n.kind);
if let Some(value) = ids.iter().find(|id| kind(id).is_none()).map(|id| id.0) {
return Err(bad(C, format!("no such object id {value}")));
}
+257 -96
View File
@@ -27,6 +27,7 @@ use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_brush::stored_art::StoredArt;
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
use vectorcraft_doc::{
Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions,
@@ -44,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Links",
[],
None,
"{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs and patterns)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots",
"{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs, patterns and the art of stored brushes)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots",
has_doc,
check
),
@@ -53,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Update Links",
[],
None,
"{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. Images and placed documents inside symbols, graph designs, patterns and opacity masks have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}",
"{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. Images and placed documents inside symbols, graph designs, patterns, opacity masks and the art of stored brushes have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}",
has_doc,
update
),
@@ -71,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Relink",
[],
None,
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs and patterns) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}",
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs, patterns and the art of stored brushes) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}",
has_doc,
relink
),
@@ -80,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Links",
[],
None,
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs and patterns (one row per stored object, never one per symbol instance or pattern tile) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern, name? (of the symbol, graph design or pattern), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots",
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs, patterns and stored brushes (one row per stored object, never one per symbol instance, pattern tile or brushed stroke) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency), owner?: {kind: layers|symbol|graphDesign|pattern|brush, name? (of the symbol, graph design, pattern or brush), index? (a brush's place in the Brushes panel, from 0), tile?: side|outerCorner|innerCorner|start|end (a pattern brush's tile), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots",
has_doc,
list
),
@@ -89,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Go To Link",
[],
None,
"{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design or pattern has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)",
"{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design, pattern or stored brush has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)",
has_doc,
go_to
),
@@ -198,6 +199,8 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
/// visited before it has (art being edited on a layer and its definition) counts once, by its
/// first place; without `ids` its content is still grouped.
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
let view = with_brush_art(d);
let d = &*view;
let mut out: Vec<Group> = vec![];
let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new();
// Looked up for every linked node: a set, not the list (which may repeat an id many times).
@@ -234,13 +237,52 @@ fn stored(d: &Document, id: NodeId) -> Option<&Node> {
d.stored_node(id).map(|(n, _)| &**n)
}
/// `d` with the art of its stored brushes set on it ([`vectorcraft_brush::stored_art`]), so that
/// the images and placed documents in that art are visited and found by their ids as stored art;
/// `d` itself when no stored brush's art holds one or the art is set on it already.
pub(crate) fn with_brush_art(d: &Document) -> Cow<'_, Document> {
if !d.mounted_art.is_empty() {
return Cow::Borrowed(d);
}
let art = StoredArt::read(d);
if art.is_empty() {
return Cow::Borrowed(d);
}
let mut d = d.clone();
art.mount(&mut d);
Cow::Owned(d)
}
/// `f` run on `d` with the art of its stored brushes set on it as [`with_brush_art`] sets it; the
/// brushes whose art `f` changed are then written back into the library, the color masks of its
/// images made for their pixels first. The ids it gives that art are the ones [`with_brush_art`]
/// gives it in the same document.
pub(crate) fn editing_brush_art<T>(d: &mut Document, f: impl FnOnce(&mut Document) -> T) -> T {
if !d.mounted_art.is_empty() {
return f(d);
}
let art = StoredArt::read(d);
if art.is_empty() {
return f(d);
}
art.mount(d);
let before: Vec<Arc<Node>> = d.mounted_art.iter().map(|(n, _)| n.clone()).collect();
let out = f(d);
if d.mounted_art.len() != before.len() || d.mounted_art.iter().zip(&before).any(|((n, _), b)| !Arc::ptr_eq(n, b)) {
super::refresh_color_masks(None, d);
}
art.write_back(d);
out
}
/// Of `ids`, the objects of `d` outside the layer tree (where [`Document::node`] finds none).
fn nested_ids(d: &Document, ids: &[NodeId]) -> std::collections::HashSet<NodeId> {
ids.iter().copied().filter(|id| d.node(*id).is_none()).collect()
}
/// `links.list`'s `owner` of an object stored at `place` of `d`, when it is not in the layer tree
/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`.
/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`, or for one in the art of a
/// stored brush, `{kind: brush, name, index, tile?, maskOf?}`.
fn owner_json(d: &Document, place: StoredPlace) -> Option<Value> {
if place.in_layers() {
return None;
@@ -250,6 +292,7 @@ fn owner_json(d: &Document, place: StoredPlace) -> Option<Value> {
StoredRoot::Symbol(i) => json!({ "kind": "symbol", "name": d.symbols.get(i).map(|s| s.name.as_str()) }),
StoredRoot::GraphDesign(i) => json!({ "kind": "graphDesign", "name": d.graph_designs.get(i).map(|g| g.name.as_str()) }),
StoredRoot::Pattern { pattern, .. } => json!({ "kind": "pattern", "name": d.patterns.get(pattern).map(|p| p.name.as_str()) }),
StoredRoot::Mounted(i) => d.mounted_art.get(i).map_or_else(|| json!({}), |(_, owner)| owner.clone()),
};
if let Some(m) = place.mask_of {
owner["maskOf"] = json!(m.0);
@@ -479,7 +522,8 @@ pub(crate) fn linked_files(d: &Document, base: LinkBase) -> Vec<LinkedFile> {
/// again (Preferences › Use Low Resolution Proxy for Linked EPS keeps the preview on screen only).
pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a, Document>, Vec<String>) {
let mut keys = BTreeSet::new();
d.visit_images(|_, im| {
// The art of the stored brushes draws its images too.
with_brush_art(d).visit_images(|_, im| {
if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) {
keys.insert(im.key.clone());
}
@@ -667,6 +711,16 @@ enum ClipTo {
File(PathData),
/// It goes into a new clip group of this path, inside the clip group that holds it.
Nest(PathData),
/// Placement Options clip it to `placement` while its DCS file draws it through `file`: it is
/// clipped to `file` as [`ClipTo::File`] clips it, and the clip group holding it is clipped to
/// `placement` as [`ClipTo::Path`] clips an object. With no `file` (the new file lacks the
/// clipping path of the clip group holding it), that group stays as [`ClipTo::Off`] leaves it,
/// clipped to `placement`.
Both { file: Option<PathData>, placement: PathData },
/// Placement Options clip it to `placement` while its DCS file draws it through `file`, and the
/// clip group holding just it is not its old file's: that clip group's clipping path becomes
/// `placement`, and it goes into a new clip group of `file` inside it.
BothInside { file: PathData, placement: PathData },
/// The clipping path of the clip group holding just it goes (a DCS file's clipping path that
/// its new file lacks): the group stays as a plain group of it, with its id, name, visibility
/// and transparency.
@@ -702,10 +756,10 @@ fn same_path(a: &PathData, b: &PathData) -> bool {
}
/// The clip an image placed with `old_xf` takes when it shows `img`, read from a file, placed with
/// `new_xf`: Placement Options' clip `clip` when they clip it, else the clipping path a DCS file
/// draws `img` through. When neither clips it, a clip group holding just it whose clipping path
/// `held` is its old DCS file's (`old_clip`, in the old pixels, under the nonzero rule its clip
/// groups get) goes.
/// `new_xf`: Placement Options' clip `clip` when they clip it, and the clipping path a DCS file
/// draws `img` through, the file's clip group inside the placement's. When neither clips it, a clip
/// group holding just it whose clipping path `held` is its old DCS file's (`old_clip`, in the old
/// pixels, under the nonzero rule its clip groups get) goes.
fn clip_to(
old_xf: Affine,
old_clip: Option<&PathData>,
@@ -714,16 +768,22 @@ fn clip_to(
new_xf: Affine,
held: Option<(&PathData, FillRule)>,
) -> ClipTo {
if let Some(clip) = clip {
return ClipTo::Path(clip_shape(clip));
}
// Whether the clip group holding just the image is the old DCS file's: a clipping path whose
// fill rule was changed in the document is not.
let file_held = match (old_clip, held) {
(Some(old), Some((held, rule))) => rule == FillRule::NonZero && same_path(&old.transformed(old_xf), held),
_ => false,
};
match img.clip.as_ref().map(|path| path.transformed(new_xf)) {
let file = img.clip.as_ref().map(|path| path.transformed(new_xf));
if let Some(clip) = clip {
let placement = clip_shape(clip);
return match file {
Some(file) if held.is_some() && !file_held => ClipTo::BothInside { file, placement },
file if file.is_some() || file_held => ClipTo::Both { file, placement },
_ => ClipTo::Path(placement),
};
}
match file {
// A clipping mask made in the document stays, with the file's clip group inside it.
Some(path) if held.is_some() && !file_held => ClipTo::Nest(path),
Some(path) => ClipTo::File(path),
@@ -747,17 +807,18 @@ struct Nested<'a> {
impl Nested<'_> {
/// Every root of `d`: the layers (whose own objects are left alone), then the art of symbols,
/// graph designs and patterns.
/// graph designs and patterns, then [`Document::mounted_art`].
fn run(mut self, d: &mut Document) {
let first = d.peek_next_id();
self.next = first;
for l in d.layers.iter_mut() {
self.update(l, false);
// Nothing is picked in the layers themselves, so no layer is clipped.
let _ = self.update(l, false);
}
for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) {
self.root(root);
}
for root in d.patterns.iter_mut().flat_map(|p| &mut p.art) {
for root in d.patterns.iter_mut().flat_map(|p| &mut p.art).chain(d.mounted_art.iter_mut().map(|(n, _)| n)) {
self.root(root);
}
for _ in first..self.next {
@@ -769,20 +830,30 @@ impl Nested<'_> {
/// mask art).
fn root(&mut self, n: &mut Arc<Node>) {
if !(self.pick)(n) {
return self.update(n, true);
if let Some(placement) = self.update(n, true) {
*n = Arc::new(self.clip_group(Arc::clone(n), placement));
}
return;
}
let (new, clip) = (self.make)(n, None);
let mut new = Arc::new(new);
self.update(&mut new, true);
let _ = self.update(&mut new, true);
*n = match clip {
ClipTo::Path(path) | ClipTo::File(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)),
ClipTo::Both { file, placement } => self.clip_both(new, file, placement),
ClipTo::BothInside { file, placement } => self.clip_both(new, Some(file), placement),
ClipTo::Keep | ClipTo::Off => new,
};
}
/// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays
/// the same node when none is there, and only the nodes on the way to one are copied.
fn update(&mut self, n: &mut Arc<Node>, nested: bool) {
/// the same node when none is there, and only the nodes on the way to one are copied. The
/// result is Placement Options' clip that `n` itself, now the clip group of a DCS file's
/// clipping path holding a picked object (or the plain group [`ClipTo::Off`] leaves), takes:
/// the clipping path of the clip group holding just `n` becomes it, else `n` goes into a new
/// clip group of it.
#[must_use]
fn update(&mut self, n: &mut Arc<Node>, nested: bool) -> Option<PathData> {
let mask = n.extras().mask.as_ref().and_then(|m| {
let mut art = Arc::clone(&m.art);
self.root(&mut art);
@@ -794,6 +865,8 @@ impl Nested<'_> {
// The clipping path and object of a plain group holding just the object, which becomes a
// clip group again.
let mut reclipped = None;
// Placement Options' clip for `n` itself.
let mut around = None;
if let Some(children) = n.children() {
// A clip group holding just the object: its clipping path changes.
let clip_path = match (&n.kind, &children[..]) {
@@ -804,9 +877,11 @@ impl Nested<'_> {
for (i, c) in children.iter().enumerate() {
if !(nested && (self.pick)(c)) {
let mut new = Arc::clone(c);
self.update(&mut new, nested);
if !Arc::ptr_eq(&new, c) {
slots.push((i, new));
match (self.update(&mut new, nested), clip_path) {
(None, _) if Arc::ptr_eq(&new, c) => {}
(None, _) => slots.push((i, new)),
(Some(placement), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, placement)), (1, new)]),
(Some(placement), _) => slots.push((i, Arc::new(self.clip_group(new, placement)))),
}
continue;
}
@@ -816,16 +891,10 @@ impl Nested<'_> {
});
let (new, clip) = (self.make)(c, held);
let mut new = Arc::new(new);
self.update(&mut new, true);
let _ = self.update(&mut new, true);
match (clip, clip_path) {
(ClipTo::Keep, _) => slots.push((i, new)),
(ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip;
}
slots.extend([(0, Arc::new(path)), (1, new)]);
}
(ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, clip)), (1, new)]),
(ClipTo::File(clip), None) if plain => reclipped = Some((clip, new)),
(ClipTo::Path(clip) | ClipTo::File(clip) | ClipTo::Nest(clip), _) => {
let group = self.clip_group(new, clip);
@@ -833,11 +902,30 @@ impl Nested<'_> {
}
(ClipTo::Off, Some(_)) if i == 1 => declipped = Some(new),
(ClipTo::Off, _) => slots.push((i, new)),
// The file's clip group stays `n`, and the placement's clip goes around it.
(ClipTo::Both { file: Some(file), placement }, Some(path)) if i == 1 => {
slots.extend([(0, with_clip(path, file)), (1, new)]);
around = Some(placement);
}
(ClipTo::Both { file: Some(file), placement }, None) if plain => {
reclipped = Some((file, new));
around = Some(placement);
}
(ClipTo::Both { file: None, placement }, Some(_)) if i == 1 => {
declipped = Some(new);
around = Some(placement);
}
(ClipTo::BothInside { file, placement }, Some(path)) if i == 1 => {
let group = self.clip_group(new, file);
slots.extend([(0, with_clip(path, placement)), (1, Arc::new(group))]);
}
(ClipTo::Both { file, placement }, _) => slots.push((i, self.clip_both(new, file, placement))),
(ClipTo::BothInside { file, placement }, _) => slots.push((i, self.clip_both(new, Some(file), placement))),
}
}
}
if mask.is_none() && slots.is_empty() && declipped.is_none() && reclipped.is_none() {
return;
return None;
}
let reclipped = reclipped.map(|(clip, object)| (Arc::new(self.clipping_path(clip)), object));
let n = Arc::make_mut(n);
@@ -849,14 +937,14 @@ impl Nested<'_> {
if let NodeKind::Group { children, clip } = &mut n.kind {
(*children, *clip) = (vec![path, object], true);
}
return;
return around;
}
// A clip group that stays as a plain group of just the object.
if let Some(object) = declipped {
if let NodeKind::Group { children, clip } = &mut n.kind {
(*children, *clip) = (vec![object], false);
}
return;
return around;
}
if let Some(children) = n.children_mut() {
for (i, c) in slots {
@@ -865,6 +953,17 @@ impl Nested<'_> {
}
}
}
around
}
/// A clip group of `placement` holding `object`, inside a clip group of `file` when there is a
/// `file` ([`ClipTo::Both`] for an object no clip group holds yet).
fn clip_both(&mut self, object: Arc<Node>, file: Option<PathData>, placement: PathData) -> Arc<Node> {
let object = match file {
Some(file) => Arc::new(self.clip_group(object, file)),
None => object,
};
Arc::new(self.clip_group(object, placement))
}
/// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers.
@@ -1035,6 +1134,15 @@ fn clip_file_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> {
Ok(())
}
/// Clipping path `path` (a node) with its path changed to `clip`.
fn with_clip(path: &Node, clip: PathData) -> Arc<Node> {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip;
}
Arc::new(path)
}
/// A clipping path `id` of `path`, unpainted.
fn clipping_path(id: NodeId, path: PathData) -> Node {
let none = vectorcraft_color::Paint::None;
@@ -1130,6 +1238,19 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
ClipTo::File(path) => clip_file_to(d, id, path),
ClipTo::Nest(path) => new_clip_group(d, id, path),
ClipTo::Off => unclip(d, id),
ClipTo::Both { file, placement } => {
match file {
Some(file) => clip_file_to(d, id, file)?,
None => unclip(d, id)?,
}
// The file's clip group (or the plain group left of it) takes the placement's clip.
let group = d.parent_of(id).ok_or(EngineError::NoNode(id))?;
clip_image_to(d, group, placement)
}
ClipTo::BothInside { file, placement } => {
clip_image_to(d, id, placement)?;
new_clip_group(d, id, file)
}
})
}
@@ -1229,8 +1350,14 @@ impl Resolved {
/// found away from their path take the new path. A link nothing is known of whose image, or placed
/// document, shows a preview (a file another app wrote kept only that) was never read: its file is
/// read now, into the object's bounds, which isn't a change to report. A placed document whose
/// file's bytes are gone isn't showing a preview. Nothing here is an undo step.
/// file's bytes are gone isn't showing a preview. Links in the art of the stored brushes count too,
/// and that art keeps the ids they are listed by. Nothing here is an undo step.
pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved {
editing_brush_art(d, |d| resolve_links(d, base, update, low_res_proxy_eps))
}
/// [`resolve`] in the art `d` stores.
fn resolve_links(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved {
let mut out = Resolved::default();
// The image (with its preview) and hash of each file read for a preview here, by path: images
// of other keys linking to the same file share them, rather than each reading, decoding and
@@ -1381,8 +1508,8 @@ fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) {
}
/// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open
/// documents, in the layers, symbols, graph designs, patterns and opacity masks: read again from
/// the new file, each as one Update Links step of its document.
/// documents, in the layers, symbols, graph designs, patterns, opacity masks and the art of the
/// stored brushes: read again from the new file, each as one Update Links step of its document.
pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) {
let path = absolute_path(path);
let targets: Vec<usize> = s
@@ -1392,7 +1519,7 @@ pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) {
.filter(|(_, st)| st.uid != saved)
.filter(|(_, st)| {
let mut placed = false;
st.doc.visit_placed(|_, p| placed |= p.link.path == path);
with_brush_art(&st.doc).visit_placed(|_, p| placed |= p.link.path == path);
placed
})
.map(|(i, _)| i)
@@ -1449,7 +1576,7 @@ fn link_target(link: &LinkInfo, base: LinkBase) -> PathBuf {
/// `d` (looking from `base`) as saved to `dest`: the links' paths relative to its folder written
/// in; `None` when they are already. A link whose file is not at its path is written relative to
/// where its file is found, else to the place its relative path names. Links in the layers,
/// symbols, graph designs, patterns and opacity masks are all set.
/// symbols, graph designs, patterns, opacity masks and the art of the stored brushes are all set.
pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option<Document> {
let dest = absolute_path(dest);
let dir = Path::new(&dest).parent()?;
@@ -1458,23 +1585,29 @@ pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option<D
let mut look = |l: &LinkInfo| {
wanted.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| relative_path(&link_target(l, base), dir));
};
d.visit_images(|_, im| im.link.iter().for_each(&mut look));
d.visit_placed(|_, p| look(&p.link));
let view = with_brush_art(d);
view.visit_images(|_, im| im.link.iter().for_each(&mut look));
view.visit_placed(|_, p| look(&p.link));
wanted.retain(|(_, stored), rel| rel != stored);
if wanted.is_empty() {
return None;
}
let key = |l: &LinkInfo| (l.path.clone(), l.relative.clone());
let mut d = d.clone();
d.update_links_where(|l| wanted.contains_key(&key(l)), |l| l.relative = wanted.get(&key(l)).cloned().flatten());
editing_brush_art(&mut d, |d| d.update_links_where(|l| wanted.contains_key(&key(l)), |l| l.relative = wanted.get(&key(l)).cloned().flatten()));
Some(d)
}
/// Sets each link of `d`, a copy of a document looking from `base`, to the path where that
/// document finds its file, and its relative path to it as that document's links have them, so
/// the copy finds the same files. Links in the layers, symbols, graph designs, patterns and opacity
/// masks are all set.
/// the copy finds the same files. Links in the layers, symbols, graph designs, patterns, opacity
/// masks and the art of the stored brushes are all set.
pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) {
editing_brush_art(d, |d| set_found_paths(d, base));
}
/// [`with_found_paths`] in the art `d` stores.
fn set_found_paths(d: &mut Document, base: LinkBase) {
// The new path and relative path of each link by what `locate` reads of it: its path, relative
// path and name.
type PathAndRelative = (String, Option<String>);
@@ -1629,7 +1762,9 @@ fn update_where(s: &mut Session, ids: Option<Vec<NodeId>>, path: Option<&str>) -
let updated: Vec<u64> = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect();
let warnings = spot_swatch_notes(&st.doc, reads.iter().map(|(_, read)| read));
if !reads.is_empty() {
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?;
s.edit("Update Links", |d, _| {
editing_brush_art(d, |d| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))
})?;
}
let mut out = json!({ "updated": updated, "missing": missing });
if !warnings.is_empty() {
@@ -1756,9 +1891,10 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?)
}
};
let doc = with_brush_art(&st.doc);
// An image or a placed document, with its link.
let image = |id: &NodeId| {
stored(&st.doc, *id).and_then(|n| match &n.kind {
stored(&doc, *id).and_then(|n| match &n.kind {
NodeKind::Image(im) => Some(im.link.as_ref()),
NodeKind::PlacedDocument(p) => Some(Some(&p.link)),
_ => None,
@@ -1788,7 +1924,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect();
let mut given: BTreeSet<NodeId> = ids.iter().copied().collect();
let mut more = vec![];
st.doc.visit_stored(|n, _| {
doc.visit_stored(|n, _| {
if let Some((l, _, _)) = linked(n)
&& paths.contains(l.path.as_str())
&& n.id.0 != 0
@@ -1799,7 +1935,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
});
ids.extend(more);
}
if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&st.doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) {
if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) {
return Err(bad(C, "copy takes images only: relink placed documents where their files are"));
}
// (ids, the file) per file to read.
@@ -1835,7 +1971,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let mut images = vec![];
let mut documents: Vec<((u32, bool, PageBox), Vec<NodeId>)> = vec![];
for id in ids {
match stored(&st.doc, id).map(|n| &n.kind) {
match stored(&doc, id).map(|n| &n.kind) {
Some(NodeKind::PlacedDocument(pl)) => {
let k = (pl.page(), pl.bounding, pl.page_box);
match documents.iter_mut().find(|(e, _)| *e == k) {
@@ -1886,9 +2022,10 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
drop(doc);
let warnings = spot_swatch_notes(&s.doc()?.doc, reads.iter().map(|(_, read)| read));
if !reads.is_empty() {
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?;
s.edit("Relink", |d, _| editing_brush_art(d, |d| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None))))?;
}
not_found.sort();
not_found.dedup();
@@ -1998,14 +2135,15 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) ->
st.selection.objects.iter().copied().filter(|id| image_of(&st.doc, *id).is_some_and(&keep)).collect()
}
/// `id`, else the first selected image.
fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
/// `id` (looked up in `doc`, the document of `st` as [`with_brush_art`] gives it), else the first
/// selected image.
fn image_param(st: &crate::DocState, doc: &Document, p: &Value, cmd: &str) -> Result<NodeId> {
let id = object_param(p, "id", cmd)?
.or_else(|| selected_images(st, |_| true).first().copied())
.ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
match image_of(&st.doc, id) {
match image_of(doc, id) {
Some(_) => Ok(id),
None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)),
None if stored(doc, id).is_none() => Err(crate::EngineError::NoNode(id)),
None => Err(bad(cmd, format!("object {} is not an image", id.0))),
}
}
@@ -2028,13 +2166,14 @@ fn placement_of(d: &Document, id: NodeId) -> Option<PlacementOptions> {
}
}
/// `id`, else the first selected image or placed document.
fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some());
/// `id` (looked up in `doc`, the document of `st` as [`with_brush_art`] gives it), else the first
/// selected image or placed document.
fn linked_param(st: &crate::DocState, doc: &Document, p: &Value, cmd: &str) -> Result<NodeId> {
let first = || st.selection.objects.iter().copied().find(|id| placement_of(doc, *id).is_some());
let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?;
match placement_of(&st.doc, id) {
match placement_of(doc, id) {
Some(_) => Ok(id),
None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)),
None if stored(doc, id).is_none() => Err(crate::EngineError::NoNode(id)),
None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))),
}
}
@@ -2141,10 +2280,12 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() };
let statuses = statuses(st);
// The layer tree's objects, top first as the Layers panel lists them, then the others in the
// order they are stored (opacity-mask art, symbols, graph designs, patterns), each once.
// order they are stored (opacity-mask art, symbols, graph designs, patterns, the art of the
// stored brushes), each once.
let doc = with_brush_art(&st.doc);
let (mut objects, mut nested) = (vec![], vec![]);
let mut seen = std::collections::HashSet::new();
st.doc.visit_stored(|n, place| {
doc.visit_stored(|n, place| {
if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) && n.id.0 != 0 && seen.insert(n.id) {
if place.in_layers() { objects.push((n, place)) } else { nested.push((n, place)) }
}
@@ -2196,7 +2337,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
if preview {
row["preview"] = json!(true);
}
if let Some(owner) = owner_json(&st.doc, place) {
if let Some(owner) = owner_json(&doc, place) {
row["owner"] = owner;
}
if interactions.contains(&n.id) {
@@ -2218,10 +2359,13 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
fn go_to(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = linked_param(st, p, "links.goTo")?;
// An object in mask art or a definition has no place of its own on the canvas: the selection
// is cleared, and the reply names what holds it.
if let Some(owner) = st.doc.stored_node(id).and_then(|(_, place)| owner_json(&st.doc, place)) {
let doc = with_brush_art(&st.doc);
let id = linked_param(st, &doc, p, "links.goTo")?;
// An object in mask art, a definition or a stored brush's art has no place of its own on the
// canvas: the selection is cleared, and the reply names what holds it.
let owner = doc.stored_node(id).and_then(|(_, place)| owner_json(&doc, place));
drop(doc);
if let Some(owner) = owner {
s.select(|_, sel| sel.set([]))?;
return Ok(json!({ "id": id.0, "onCanvas": false, "owner": owner }));
}
@@ -2233,7 +2377,8 @@ fn go_to(s: &mut Session, p: &Value) -> Result<Value> {
fn embed(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.embed";
let st = s.doc()?;
let kind = |id: &NodeId| stored(&st.doc, *id).map(|n| &n.kind);
let doc = with_brush_art(&st.doc);
let kind = |id: &NodeId| stored(&doc, *id).map(|n| &n.kind);
let linked = |id: &NodeId| match kind(id) {
Some(NodeKind::Image(im)) => im.link.is_some(),
Some(NodeKind::PlacedDocument(_)) => true,
@@ -2285,17 +2430,20 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
_ => missing.extend(g.ids),
}
}
drop(doc);
if !done.is_empty() || !documents.is_empty() {
s.edit("Embed", |d, _| {
for (ids, read) in reads {
install(d, &ids, read, Some(Preserve::Bounds), None)?;
}
for id in &done {
if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) {
im.link = None;
editing_brush_art(d, |d| {
for (ids, read) in reads {
install(d, &ids, read, Some(Preserve::Bounds), None)?;
}
}
documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C))
for id in &done {
if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) {
im.link = None;
}
}
documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C))
})
})?;
}
done.extend(documents);
@@ -2318,9 +2466,10 @@ fn convert_image(bytes: &[u8], ext: &str) -> std::result::Result<Vec<u8>, String
fn unembed(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.unembed";
let st = s.doc()?;
let id = image_param(st, p, C)?;
let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?;
let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?;
let doc = with_brush_art(&st.doc);
let id = image_param(st, &doc, p, C)?;
let n = stored(&doc, id).ok_or(crate::EngineError::NoNode(id))?;
let im = image_of(&doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?;
if im.link.is_some() {
return Err(bad(C, format!("image {} is linked already", id.0)));
}
@@ -2345,7 +2494,8 @@ fn unembed(s: &mut Session, p: &Value) -> Result<Value> {
let img = linked_image(&bytes, Some(&path))?;
write_file(&path, &bytes)?;
let read = (link_info(&path, &bytes), img);
s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?;
drop(doc);
s.edit("Unembed", |d, _| editing_brush_art(d, |d| install(d, &[id], read, Some(Preserve::Bounds), None)))?;
Ok(json!({ "id": id.0, "path": path }))
}
@@ -2403,9 +2553,10 @@ fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) {
(json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]), json!((-b).atan2(a).to_degrees() + 0.0))
}
/// Link Info of placed document `id`.
fn placed_info(st: &crate::DocState, id: NodeId) -> Result<Value> {
let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?;
/// Link Info of placed document `id` (looked up in `doc`, the document of `st` as
/// [`with_brush_art`] gives it).
fn placed_info(st: &crate::DocState, doc: &Document, id: NodeId) -> Result<Value> {
let n = stored(doc, id).ok_or(crate::EngineError::NoNode(id))?;
let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) };
let size = n.geometric_bounds().unwrap_or_default();
let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0));
@@ -2430,13 +2581,19 @@ fn placed_info(st: &crate::DocState, id: NodeId) -> Result<Value> {
fn info(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.info";
let id = linked_param(s.doc()?, p, C)?;
if image_of(&s.doc()?.doc, id).is_none() {
return placed_info(s.doc()?, id);
}
let id = {
let st = s.doc()?;
let doc = with_brush_art(&st.doc);
let id = linked_param(st, &doc, p, C)?;
if image_of(&doc, id).is_none() {
return placed_info(st, &doc, id);
}
id
};
let mut out = super::place::image_info(s, &json!({ "id": id.0 }))?;
let st = s.doc()?;
let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?;
let doc = with_brush_art(&st.doc);
let im = image_of(&doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?;
let (px, py) = file_pt(&st.doc, &im.key);
out["effectivePpi"] = out["ppi"].take();
out["ppi"] = json!([72.0 / px, 72.0 / py]);
@@ -2453,9 +2610,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.placementOptions";
let st = s.doc()?;
let doc = with_brush_art(&st.doc);
let ids =
ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect());
if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) {
ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&doc, *id).is_some()).collect());
if let Some(id) = ids.iter().find(|id| placement_of(&doc, **id).is_none()) {
return Err(bad(C, format!("object {} is not an image or a linked file", id.0)));
}
if ids.is_empty() {
@@ -2474,7 +2632,8 @@ fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "clip must be true or false"))?),
};
let first = ids.first().and_then(|id| placement_of(&st.doc, *id)).unwrap_or_default();
let first = ids.first().and_then(|id| placement_of(&doc, *id)).unwrap_or_default();
drop(doc);
if preserve.is_none() && align.is_none() && clip.is_none() {
return Ok(json!({ "placement": placement_json(first) }));
}
@@ -2484,11 +2643,13 @@ fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
o.clip = clip.unwrap_or(o.clip);
};
s.edit("Placement Options", |d, _| {
for id in &ids {
if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) {
set(o);
editing_brush_art(d, |d| {
for id in &ids {
if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) {
set(o);
}
}
}
});
Ok(())
})?;
let mut shown = first;
+15 -2
View File
@@ -149,9 +149,22 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
};
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?;
// Saved steps ([`Session::replay`]) can hold values a direct call refuses, recorded before these
// checks: a negative radius is 0, a whole side count of 0 or more is brought into 3 to 1000, and
// a fractional or negative one is ignored. A whole number written as 5.0 counts as 5, as in a
// direct call.
let replaying = s.replaying();
let radius = if replaying {
number("radius", |_| true, "a number")?.map(|r| r.max(0.0))
} else {
number("radius", |r| r >= 0.0, "a radius of 0 or more")?
};
// A polygon's side count: a whole number from 3 to 1000.
let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64);
let sides = if replaying {
number("sides", |_| true, "a number")?.filter(|n| n.fract() == 0.0 && *n >= 0.0).map(|n| n.min(1000.0) as u64)
} else {
number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64)
};
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
+15 -11
View File
@@ -229,7 +229,7 @@ impl<'a> Collector<'a> {
Some((d, base)) => super::links::linked_files(d, base),
None => super::links::linked_files(doc, LinkBase::file(Some(source))),
};
doc.visit_placed(|_, p| {
super::links::with_brush_art(doc).visit_placed(|_, p| {
let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box);
let pages = placed.entry(p.link.path.clone()).or_default();
if !pages.contains(&page) {
@@ -389,16 +389,20 @@ impl<'a> Collector<'a> {
}
if self.opts.relink {
// The child files have already been rewritten; the hash and size of each
// target must reflect the *packaged* bytes rather than the original.
doc.update_links(|link: &mut LinkInfo| {
if let Some(copy) = local.get(&link.path) {
let relative = relative_in_package(destination, &copy.name);
link.path = self.root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(&copy.name).to_string_lossy().into_owned());
link.relative = Some(relative);
link.size = copy.size;
link.hash = copy.hash.clone();
link.modified = None;
}
// target must reflect the *packaged* bytes rather than the original. The links in the
// art of the stored brushes are written back into the brush library.
let root = &self.root;
super::links::editing_brush_art(doc, |doc| {
doc.update_links(|link: &mut LinkInfo| {
if let Some(copy) = local.get(&link.path) {
let relative = relative_in_package(destination, &copy.name);
link.path = root.as_ref().map_or_else(|| copy.name.clone(), |r| r.join(&copy.name).to_string_lossy().into_owned());
link.relative = Some(relative);
link.size = copy.size;
link.hash = copy.hash.clone();
link.modified = None;
}
})
});
}
if let Some(s) = self.sections.get_mut(section) {
+7 -5
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swap Fill and Stroke",
[],
Some("Shift+X"),
"{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)",
"{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight, and on a graph label the swapped paints of its characters for every label of its kind (exceptions: docs/mcp.md)",
has_doc,
swap
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Default Fill and Stroke",
[],
Some("D"),
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and on a graph label the black fill and no stroke of its characters for every label of its kind (exceptions: docs/mcp.md)",
has_doc,
default_paint
),
@@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Invert",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md); no undo step when no color changes → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
),
@@ -925,7 +925,8 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
}
// The swap writes the paint of the topmost fill and stroke, not their opacity.
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
let labels = super::graph::capture_label_changes(&before, d, &ids, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels))
})?;
}
if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 {
@@ -954,7 +955,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
let labels = super::graph::capture_label_changes(&before, d, &ids, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels))
})?;
}
super::newart::reset(s);
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -315,13 +315,13 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
let independent = switch_or(p, "independent", false, "path.setHandle")?;
// A handle already there, on an anchor already of the kind it would get, changes nothing and
// records no undo step.
if let Some(a) = anchor_of(s, id, si, ai)? {
let mut b = a;
b.set_handle(out, pos, independent);
if b == a {
return ok();
}
// records no undo step. The handle is compared directly: `set_handle` would recompute the
// other handle of a smooth anchor, which need not give back the same value.
if let Some(a) = anchor_of(s, id, si, ai)?
&& (if out { a.h_out } else { a.h_in }) == pos
&& !(independent && a.kind != AnchorKind::Corner)
{
return ok();
}
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
+16 -5
View File
@@ -19,7 +19,7 @@ use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace}
use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid};
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
use super::distortcmds::{grid_of, silent, store_grid};
use super::distortcmds::{grid_kept, grid_of, silent, store_grid};
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, switch_or, unique_name};
pub fn specs() -> Vec<CommandSpec> {
@@ -261,10 +261,21 @@ pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?;
// The objects stay attached and the view options stay as they were.
let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g };
s.edit("Perspective Grid Preset", |d, _| {
store_grid(d, &g);
Ok(())
})?;
// When the document holds this grid already, apart from whether it shows and its active plane,
// the preset only sets those two, without an undo step (as perspective.grid.show and
// perspective.plane.set do).
let view = PerspectiveGrid { visible: old.visible, plane: old.plane, ..g.clone() };
if !grid_kept(&s.doc()?.doc, &view) {
s.edit("Perspective Grid Preset", |d, _| {
store_grid(d, &g);
Ok(())
})?;
} else if (old.visible, old.plane) != (g.visible, g.plane) {
silent(s, |cur| {
cur.visible = g.visible;
cur.plane = g.plane;
})?;
}
Ok(g.definition_json())
}
+4 -2
View File
@@ -962,8 +962,10 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
(None, [id]) => *id,
_ => return Err(bad(C, "select one image or pass id")),
};
// An image in opacity-mask art or a definition's art is found by its id too (`links.list`).
let n = st.doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?;
// An image in opacity-mask art, a definition's art or a stored brush's art is found by its id
// too (`links.list`).
let doc = super::links::with_brush_art(&st.doc);
let n = doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?;
let NodeKind::Image(im) = &n.kind else { return Err(bad(C, format!("object {} is not an image", id.0))) };
// Effective resolution: 72 pt per inch over the points one pixel spans along each axis.
let [a, b, c, d, ..] = im.xf.as_coeffs();

Some files were not shown because too many files have changed in this diff Show More