mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
Merge main into fix/dimension-second-point-guides
This commit is contained in:
Generated
+1
@@ -6386,6 +6386,7 @@ dependencies = [
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"rustix 1.1.6",
|
||||
"serde_json",
|
||||
"socket2",
|
||||
"tempfile",
|
||||
|
||||
@@ -18,6 +18,8 @@ gate: all six core workflows (path drawing and editing, paint and style, type, l
|
||||
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
|
||||
blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
> **Gujarati status (2026-11-14, #6613):** 4,673 catalog rows: the original 4,286 remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial, and the pinned CI/release fonts and web startup list contain no Gujarati face; web selection stays unavailable until a Gujarati interface face is bundled. Scores and estimates unchanged.
|
||||
|
||||
## Headline numbers
|
||||
|
||||
| | Value | Kind |
|
||||
@@ -96,6 +98,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% |
|
||||
| Spanish | `es` | full | 100% |
|
||||
| Hindi | `hi` | catalog complete; machine-assisted draft, native review pending | complete under the scanner |
|
||||
| Gujarati | `gu` | 4,673 contributor-reviewed catalog rows; 11 corrections applied to the 387 additions | menus and dialogs; status/error messages partial |
|
||||
| Arabic | `ar` | none (no right-to-left interface) | 0% |
|
||||
| French | `fr` | full | 100% |
|
||||
| Portuguese (Brazil) | `pt-br` | full | 100% |
|
||||
@@ -218,6 +221,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. |
|
||||
| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. |
|
||||
@@ -226,6 +230,8 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
|
||||
| 2026-11-03 | minor | UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
|
||||
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
|
||||
| 2026-11-12 | minor | Gujarati (`gu`): reviewed by a native speaker; zero/one singular plurals (CLDR); Gujarati craft-fonts faces join the UI fonts; strings added on main since branching translated (4,286 rows) |
|
||||
| 2026-11-11 | minor | Gujarati (`gu`): 4,235-row machine-assisted interface catalog, one/other plurals, desktop Gujarati font fallback; messages not yet translated; native review pending |
|
||||
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
|
||||
| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
|
||||
| 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. |
|
||||
|
||||
@@ -110,6 +110,10 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
||||
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
|
||||
# through winit and rfd.
|
||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
# The Wayland clipboard's pipes (src/clipboard.rs): `poll` and the nonblocking flag give up a read
|
||||
# or send when the other app moves no data for a while. Apache-2.0 WITH LLVM-exception OR
|
||||
# Apache-2.0 OR MIT, already in the build through the workspace.
|
||||
rustix = { workspace = true, features = ["std", "event", "fs"] }
|
||||
|
||||
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
|
||||
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
|
||||
|
||||
@@ -1790,15 +1790,17 @@ mod portable {
|
||||
/// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads
|
||||
/// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and
|
||||
/// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for
|
||||
/// a paste to wait for off the UI thread, as arboard's are.
|
||||
/// 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")]
|
||||
mod wayland {
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::AsFd as _;
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::os::fd::{AsFd, BorrowedFd};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rustix::event::{PollFd, PollFlags, Timespec};
|
||||
use vectorcraft_engine::cmd::clipboard::names_a_picture;
|
||||
use vectorcraft_ui_egui::ClipboardRead;
|
||||
use wayland_client::backend::ObjectData;
|
||||
@@ -1827,6 +1829,11 @@ mod wayland {
|
||||
const MAX_SENDS: usize = 8;
|
||||
/// The most types of an offer that are kept (untrusted data).
|
||||
const MAX_TYPES: usize = 256;
|
||||
/// How long a read or send waits for the other app to move a byte through the pipe before it
|
||||
/// fails, VectorCraft's own limit; the wait starts again after each byte moved.
|
||||
const IDLE_WAIT: Duration = LOAD_WAIT;
|
||||
/// The most bytes a read takes from the pipe at a time.
|
||||
const CHUNK: usize = 64 << 10;
|
||||
|
||||
/// What a read gives: the bytes, or why there are none.
|
||||
type Got = Result<Vec<u8>, String>;
|
||||
@@ -1898,37 +1905,117 @@ mod wayland {
|
||||
Some(first)
|
||||
}
|
||||
|
||||
/// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read.
|
||||
fn read_capped(from: impl Read, cap: usize) -> Got {
|
||||
let mut data = vec![];
|
||||
from.take(cap as u64 + 1).read_to_end(&mut data).map_err(|e| format!("couldn't read the clipboard's data: {e}"))?;
|
||||
if data.len() > cap {
|
||||
return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20));
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Makes `fd` nonblocking, its other flags kept.
|
||||
fn nonblocking(fd: BorrowedFd<'_>) -> std::io::Result<()> {
|
||||
let flags = rustix::fs::fcntl_getfl(fd)?;
|
||||
rustix::fs::fcntl_setfl(fd, flags | rustix::fs::OFlags::NONBLOCK)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Waits until `fd` is ready for `events`, closed or failed (true), or `until` passes (false);
|
||||
/// None waits without a limit. A wait a signal cuts short goes on until `until`.
|
||||
fn ready(fd: BorrowedFd<'_>, events: PollFlags, until: Option<Instant>) -> std::io::Result<bool> {
|
||||
loop {
|
||||
let left = until.map(|t| t.saturating_duration_since(Instant::now()));
|
||||
let timeout = left.and_then(|d| Timespec::try_from(d).ok());
|
||||
let mut fds = [PollFd::new(&fd, events)];
|
||||
match rustix::event::poll(&mut fds, timeout.as_ref()) {
|
||||
Ok(n) => return Ok(n > 0),
|
||||
Err(rustix::io::Errno::INTR) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read and a
|
||||
/// pipe that is left open with no byte coming for `idle`. A read that fails gives none of the
|
||||
/// bytes it took, and closes `from`.
|
||||
fn read_capped(mut from: impl Read + AsFd, cap: usize, idle: Duration) -> Got {
|
||||
let failed = |e: std::io::Error| format!("couldn't read the clipboard's data: {e}");
|
||||
nonblocking(from.as_fd()).map_err(failed)?;
|
||||
let (mut data, mut chunk) = (vec![], vec![0u8; CHUNK]);
|
||||
let mut until = idle_until(idle);
|
||||
loop {
|
||||
match from.read(&mut chunk) {
|
||||
Ok(0) => return Ok(data),
|
||||
Ok(n) => {
|
||||
let got = chunk.get(..n).ok_or_else(|| failed(ErrorKind::InvalidData.into()))?;
|
||||
if data.len() + got.len() > cap {
|
||||
return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20));
|
||||
}
|
||||
data.extend_from_slice(got);
|
||||
until = idle_until(idle);
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => {}
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => {
|
||||
if !ready(from.as_fd(), PollFlags::IN, until).map_err(failed)? {
|
||||
return Err("the app that copied stopped sending the clipboard's data".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` written to `to`: an error when the app that pastes closes its end (the process
|
||||
/// ignores `SIGPIPE`) or takes no byte for `idle`.
|
||||
fn send(mut to: impl Write + AsFd, data: &[u8], idle: Duration) -> std::io::Result<()> {
|
||||
nonblocking(to.as_fd())?;
|
||||
let (mut sent, mut until) = (0, idle_until(idle));
|
||||
while let Some(rest) = data.get(sent..).filter(|rest| !rest.is_empty()) {
|
||||
match to.write(rest) {
|
||||
Ok(0) => return Err(ErrorKind::WriteZero.into()),
|
||||
Ok(n) => {
|
||||
sent += n;
|
||||
until = idle_until(idle);
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => {}
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => {
|
||||
if !ready(to.as_fd(), PollFlags::OUT, until)? {
|
||||
return Err(ErrorKind::TimedOut.into());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The threads that send a Copy's data to the apps that paste it, counted.
|
||||
#[derive(Clone, Default)]
|
||||
struct Sends(Arc<AtomicUsize>);
|
||||
#[derive(Clone)]
|
||||
struct Sends {
|
||||
running: Arc<AtomicUsize>,
|
||||
/// How long a send waits for the app that pastes to take a byte.
|
||||
idle: Duration,
|
||||
}
|
||||
|
||||
impl Default for Sends {
|
||||
fn default() -> Self {
|
||||
Self { running: Arc::default(), idle: IDLE_WAIT }
|
||||
}
|
||||
}
|
||||
|
||||
impl Sends {
|
||||
/// `data` written to `to` on a thread of its own, which then closes `to`; an app that
|
||||
/// closes its end early ends the write (the process ignores `SIGPIPE`). While
|
||||
/// [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false.
|
||||
fn start(&self, mut to: impl Write + Send + 'static, data: Arc<[u8]>) -> bool {
|
||||
if self.0.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
/// `data` written to `to` on a thread of its own ([`send`]), which then closes `to`; an app
|
||||
/// that closes its end early or takes no byte for the send's idle wait ends the write.
|
||||
/// While [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false.
|
||||
fn start(&self, to: impl Write + AsFd + Send + 'static, data: Arc<[u8]>) -> bool {
|
||||
if self.running.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS {
|
||||
self.running.fetch_sub(1, Ordering::SeqCst);
|
||||
return false;
|
||||
}
|
||||
let count = self.0.clone();
|
||||
let send = move || {
|
||||
let _ = to.write_all(&data);
|
||||
drop(to);
|
||||
let (count, idle) = (self.running.clone(), self.idle);
|
||||
let work = move || {
|
||||
let _ = send(to, &data, idle);
|
||||
count.fetch_sub(1, Ordering::SeqCst);
|
||||
};
|
||||
if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(send).is_err() {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(work).is_err() {
|
||||
self.running.fetch_sub(1, Ordering::SeqCst);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
@@ -1937,7 +2024,7 @@ mod wayland {
|
||||
/// How many are being written.
|
||||
#[cfg(test)]
|
||||
fn running(&self) -> usize {
|
||||
self.0.load(Ordering::SeqCst)
|
||||
self.running.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2298,7 +2385,7 @@ mod wayland {
|
||||
|
||||
impl Receiver {
|
||||
/// The clipboard's data of type `wire`, read until the app that copied closes the pipe,
|
||||
/// [`MAX_BYTES`] at most.
|
||||
/// [`MAX_BYTES`] at most; an error when that app sends no byte for [`IDLE_WAIT`].
|
||||
fn receive(&self, wire: String) -> Got {
|
||||
let offer = lock(&self.objects).selection.clone().ok_or("the clipboard is empty")?;
|
||||
let (from, to) = std::io::pipe().map_err(|e| format!("couldn't open a pipe for the clipboard's data: {e}"))?;
|
||||
@@ -2307,7 +2394,7 @@ mod wayland {
|
||||
// holds the only one, and the read ends when that app closes it.
|
||||
drop(to);
|
||||
self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))?;
|
||||
read_capped(from, MAX_BYTES)
|
||||
read_capped(from, MAX_BYTES, IDLE_WAIT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2557,31 +2644,86 @@ mod wayland {
|
||||
assert_eq!(keyboard_serial(&modifiers(9), None), None);
|
||||
}
|
||||
|
||||
/// A wait no test reaches: the other end of each pipe below either moves data or closes.
|
||||
const LONG: Duration = Duration::from_secs(600);
|
||||
|
||||
/// A pipe holding `data`, its write end closed when `close`.
|
||||
fn pipe_with(data: &[u8], close: bool) -> (std::io::PipeReader, Option<std::io::PipeWriter>) {
|
||||
let (from, mut to) = std::io::pipe().unwrap();
|
||||
to.write_all(data).unwrap();
|
||||
(from, (!close).then_some(to))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_stops_at_its_limit() {
|
||||
assert_eq!(MAX_BYTES, 64 * 1024 * 1024);
|
||||
assert_eq!(read_capped(&b"12345678"[..], 8), Ok(b"12345678".to_vec()));
|
||||
assert_eq!(read_capped(&b""[..], 8), Ok(vec![]));
|
||||
assert_eq!(read_capped(&[0u8; 9][..], 8), Err("the clipboard's data is larger than 0 MiB".to_string()));
|
||||
assert_eq!(read_capped(&vec![0u8; (2 << 20) + 1][..], 2 << 20), Err("the clipboard's data is larger than 2 MiB".to_string()));
|
||||
/// A pipe that breaks.
|
||||
struct Broken;
|
||||
impl Read for Broken {
|
||||
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
assert_eq!(IDLE_WAIT, LOAD_WAIT);
|
||||
assert_eq!(read_capped(pipe_with(b"12345678", true).0, 8, Duration::ZERO), Ok(b"12345678".to_vec()));
|
||||
assert_eq!(read_capped(pipe_with(b"", true).0, 8, Duration::ZERO), Ok(vec![]));
|
||||
assert_eq!(read_capped(pipe_with(&[0u8; 9], true).0, 8, Duration::ZERO), Err("the clipboard's data is larger than 0 MiB".to_string()));
|
||||
// More than a pipe holds, from an app that is still sending.
|
||||
let (from, to) = std::io::pipe().unwrap();
|
||||
assert!(Sends::default().start(to, vec![0u8; (2 << 20) + 1].into()));
|
||||
assert_eq!(read_capped(from, 2 << 20, LONG), Err("the clipboard's data is larger than 2 MiB".to_string()));
|
||||
// A pipe that can't be read: the write end.
|
||||
let (_, to) = std::io::pipe().unwrap();
|
||||
let unreadable = std::fs::File::from(std::os::fd::OwnedFd::from(to));
|
||||
assert!(read_capped(unreadable, 8, LONG).is_err_and(|e| e.starts_with("couldn't read the clipboard's data")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_from_an_app_that_stops_sending_fails_and_closes_the_pipe() {
|
||||
let stopped = Err("the app that copied stopped sending the clipboard's data".to_string());
|
||||
// Some bytes and then nothing, the pipe left open: none of them are pasted.
|
||||
let (from, to) = pipe_with(b"part", false);
|
||||
assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped);
|
||||
// The read end is closed, so that app's next write fails.
|
||||
let mut to = to.unwrap();
|
||||
assert_eq!(to.write(b"x").map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe));
|
||||
// Nothing at all, and data that fills the limit exactly with more never coming.
|
||||
let (from, _held) = pipe_with(b"", false);
|
||||
assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped);
|
||||
let (from, _held) = pipe_with(b"12345678", false);
|
||||
assert_eq!(read_capped(from, 8, Duration::ZERO), stopped);
|
||||
// The next read gets its data.
|
||||
assert_eq!(read_capped(pipe_with(b"words", true).0, MAX_BYTES, Duration::ZERO), Ok(b"words".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_send_to_an_app_that_stops_reading_fails_and_frees_its_thread() {
|
||||
let data: Arc<[u8]> = vec![3u8; 300_000].into();
|
||||
// More than a pipe holds, to an app that never reads.
|
||||
let (_held, to) = std::io::pipe().unwrap();
|
||||
assert_eq!(send(to, &data, Duration::ZERO).map_err(|e| e.kind()), Err(ErrorKind::TimedOut));
|
||||
// An app that closes its end before reading.
|
||||
let (from, to) = std::io::pipe().unwrap();
|
||||
drop(from);
|
||||
assert_eq!(send(to, &data, LONG).map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe));
|
||||
// A send thread to an app that does not read until the send gives up, its end still open.
|
||||
let sends = Sends { idle: Duration::ZERO, ..Sends::default() };
|
||||
let (from, to) = std::io::pipe().unwrap();
|
||||
assert!(sends.start(to, data.clone()));
|
||||
while sends.running() > 0 {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(read_capped(Broken, 8).is_err_and(|e| e.starts_with("couldn't read the clipboard's data")));
|
||||
// What the pipe held when the send gave up, then the end of the data: the send closed it.
|
||||
let got = read_capped(from, MAX_BYTES, Duration::ZERO).map(|d| d.len());
|
||||
assert!(got.as_ref().is_ok_and(|&n| n > 0 && n < data.len()), "{got:?}");
|
||||
// The thread is free for the next paste.
|
||||
let (from, to) = std::io::pipe().unwrap();
|
||||
assert!(sends.start(to, b"words"[..].into()));
|
||||
assert_eq!(read_capped(from, MAX_BYTES, LONG), Ok(b"words".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copys_data_is_sent_through_the_pipe_and_closed() {
|
||||
let sends = Sends::default();
|
||||
assert_eq!(sends.idle, IDLE_WAIT);
|
||||
let (from, to) = std::io::pipe().unwrap();
|
||||
let data: Arc<[u8]> = vec![7u8; 300_000].into();
|
||||
assert!(sends.start(to, data.clone()));
|
||||
// More than a pipe holds: the write ends once all of it is read, then the pipe closes.
|
||||
assert_eq!(read_capped(from, MAX_BYTES).as_deref(), Ok(&data[..]));
|
||||
assert_eq!(read_capped(from, MAX_BYTES, LONG).as_deref(), Ok(&data[..]));
|
||||
// An app that closes its end before reading ends the send.
|
||||
let (from, to) = std::io::pipe().unwrap();
|
||||
drop(from);
|
||||
@@ -2593,9 +2735,9 @@ mod wayland {
|
||||
|
||||
#[test]
|
||||
fn pastes_that_read_nothing_hold_a_few_sends_at_most() {
|
||||
let sends = Sends::default();
|
||||
let sends = Sends { idle: LONG, ..Sends::default() };
|
||||
let data: Arc<[u8]> = vec![1u8; 300_000].into();
|
||||
// Apps that ask for the data and never read it.
|
||||
// Apps that ask for the data and never read it, within the sends' idle wait.
|
||||
let stalled: Vec<_> = (0..MAX_SENDS)
|
||||
.map(|_| {
|
||||
let (from, to) = std::io::pipe().unwrap();
|
||||
@@ -2606,7 +2748,7 @@ mod wayland {
|
||||
// The next paste gets its pipe closed at once, with nothing written.
|
||||
let (from, to) = std::io::pipe().unwrap();
|
||||
assert!(!sends.start(to, data.clone()));
|
||||
assert_eq!(read_capped(from, MAX_BYTES), Ok(vec![]));
|
||||
assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), Ok(vec![]));
|
||||
// Once those apps close their pipes, the sends end and others are sent again.
|
||||
drop(stalled);
|
||||
while sends.running() > 0 {
|
||||
@@ -2614,7 +2756,7 @@ mod wayland {
|
||||
}
|
||||
let (from, to) = std::io::pipe().unwrap();
|
||||
assert!(sends.start(to, data.clone()));
|
||||
assert_eq!(read_capped(from, MAX_BYTES).map(|d| d.len()), Ok(data.len()));
|
||||
assert_eq!(read_capped(from, MAX_BYTES, LONG).map(|d| d.len()), Ok(data.len()));
|
||||
}
|
||||
|
||||
/// What a clipboard holds, by type.
|
||||
|
||||
@@ -373,11 +373,14 @@ fn main() -> std::process::ExitCode {
|
||||
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
|
||||
// and the other choices do so when a pen is attached, where the pen draws (#2028, #4967).
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut forced_to_x11 = false;
|
||||
#[cfg(target_os = "linux")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
|
||||
if xwayland::x11_libraries_found() {
|
||||
forced_to_x11 = true;
|
||||
if pref != Some("x11") {
|
||||
log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)");
|
||||
} else {
|
||||
@@ -393,6 +396,11 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
options
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let forced_to_x11 = false;
|
||||
// Preferences › User Interface notes under System Title Bar when the desktop draws the frame
|
||||
// with it on or off.
|
||||
let desktop_keeps_frame = titlebar::desktop_keeps_frame(forced_to_x11);
|
||||
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
|
||||
// is down; the message hook notes each pointer contact and whether it starts with the barrel
|
||||
// button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the
|
||||
@@ -595,6 +603,7 @@ fn main() -> std::process::ExitCode {
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
app.caption_layout = caption_layout;
|
||||
app.desktop_frame = !custom_titlebar;
|
||||
app.desktop_keeps_frame = desktop_keeps_frame;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx).with_automation_roots(roots.clone());
|
||||
|
||||
@@ -346,6 +346,34 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Preferences says under System Title Bar that the desktop draws the frame with it on or off
|
||||
/// exactly where the start frame without VectorCraft Title Bar on Every Desktop is the desktop's
|
||||
/// with System Title Bar off as well as on and `VECTORCRAFT_TITLEBAR` is unset: Linux and the BSDs
|
||||
/// only.
|
||||
#[test]
|
||||
fn the_desktop_keeps_the_frame_where_system_title_bar_does_not_change_it() {
|
||||
use crate::titlebar::desktop_keeps_frame_from;
|
||||
use craft_window::{Frame, Layout};
|
||||
let linux = cfg!(not(any(target_os = "macos", target_os = "windows")));
|
||||
for probed in [Frame::Desktop, Frame::App(Layout::default())] {
|
||||
for forced_to_x11 in [false, true] {
|
||||
let frame = super::start_frame(probed.clone(), None, forced_to_x11, false);
|
||||
let either_way = !super::custom_titlebar_from(&frame, false) && !super::custom_titlebar_from(&frame, true);
|
||||
assert_eq!(desktop_keeps_frame_from(&probed, None, forced_to_x11), linux && either_way, "{probed:?} {forced_to_x11}");
|
||||
}
|
||||
}
|
||||
// KDE, Sway and X11, and GNOME through XWayland, keep the desktop's frame; GNOME on Wayland does not.
|
||||
assert_eq!(desktop_keeps_frame_from(&Frame::Desktop, None, false), linux);
|
||||
assert_eq!(desktop_keeps_frame_from(&Frame::App(Layout::default()), None, true), linux);
|
||||
assert!(!desktop_keeps_frame_from(&Frame::App(Layout::default()), None, false));
|
||||
// While `VECTORCRAFT_TITLEBAR` chooses the frame, System Title Bar does not apply and there is no note.
|
||||
for (frame, overridden) in [(Frame::App(Layout::default()), Some(true)), (Frame::Desktop, Some(false))] {
|
||||
for forced_to_x11 in [false, true] {
|
||||
assert!(!desktop_keeps_frame_from(&frame, overridden, forced_to_x11), "{overridden:?} {forced_to_x11}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
|
||||
@@ -191,6 +191,25 @@ pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> (
|
||||
(custom_titlebar_from(&frame, opted_into_system), caption_layout_from(&frame))
|
||||
}
|
||||
|
||||
/// Whether the desktop would draw this window's frame with System Title Bar on or off while
|
||||
/// VectorCraft Title Bar on Every Desktop is off: `VECTORCRAFT_TITLEBAR` is not set to `app`,
|
||||
/// `system` or `desktop` (`overridden` is `None`), and the probe chose
|
||||
/// [`craft_window::Frame::Desktop`] or the window opens through XWayland (`forced_to_x11`)
|
||||
/// ([`app_draws_frame`]). The saved VectorCraft Title Bar on Every Desktop is left out, so the value
|
||||
/// stays `true` on such a desktop while that preference gives the app the frame. Always `false` on
|
||||
/// macOS and Windows, where System Title Bar decides the frame by itself.
|
||||
pub(crate) fn desktop_keeps_frame_from(probed: &craft_window::Frame, overridden: Option<bool>, forced_to_x11: bool) -> bool {
|
||||
let probed_app = matches!(probed, craft_window::Frame::App(_));
|
||||
cfg!(not(any(target_os = "macos", target_os = "windows"))) && overridden.is_none() && !app_draws_frame(probed_app, None, forced_to_x11, false)
|
||||
}
|
||||
|
||||
/// [`desktop_keeps_frame_from`] for this start's probe, given whether the launch path opens the
|
||||
/// window through XWayland.
|
||||
pub(crate) fn desktop_keeps_frame(forced_to_x11: bool) -> bool {
|
||||
let choice = probed_choice();
|
||||
desktop_keeps_frame_from(choice.frame(), choice.overridden(), forced_to_x11)
|
||||
}
|
||||
|
||||
pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
|
||||
@@ -114,51 +114,9 @@ fn strip_brushes(n: &mut Node) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer.
|
||||
pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
||||
let amount = amount.clamp(0.0, 1.0);
|
||||
for it in &mut n.appearance.items {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
for p in [&mut r.style.fill, &mut r.style.stroke] {
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
tint_node(Arc::make_mut(c), color, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's
|
||||
/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
|
||||
/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
|
||||
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
|
||||
let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
|
||||
Some((f.paint.color()?, f.opacity))
|
||||
}
|
||||
|
||||
/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic
|
||||
/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]).
|
||||
pub fn instance_art(art: &Node, inst: &Node) -> Node {
|
||||
let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst);
|
||||
if let Some((c, amount)) = stain(inst) {
|
||||
tint_node(&mut a, &c, amount);
|
||||
}
|
||||
a
|
||||
}
|
||||
/// The Symbol Stainer's mix, an instance's stain and its stained art, from
|
||||
/// [`vectorcraft_doc::symbol_overrides`], which the effects that reshape an instance also read.
|
||||
pub use vectorcraft_doc::symbol_overrides::{instance_art, stain, tint_node};
|
||||
|
||||
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
|
||||
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
|
||||
|
||||
@@ -683,7 +683,7 @@ impl<'a> Scene<'a> {
|
||||
{
|
||||
// Symbols holding instances of symbols are written in full for each instance,
|
||||
// empty or not: every instance's art counts against one budget for the export.
|
||||
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
|
||||
let count = art.count_with_masks() as u64;
|
||||
// Counted as `place_symbol_art_unclipped` places it: no clip groups.
|
||||
let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc));
|
||||
|
||||
@@ -1395,6 +1395,21 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() {
|
||||
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
|
||||
#[test]
|
||||
fn a_stained_instance_is_written_in_its_stained_color() {
|
||||
let mut d = doc_with(|d| {
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) });
|
||||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
|
||||
vec![n]
|
||||
});
|
||||
let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
let colors: Vec<Option<String>> = entities(&out).iter().map(|e| get(e, 420).map(str::to_string)).collect();
|
||||
assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() {
|
||||
// Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty
|
||||
|
||||
@@ -363,6 +363,14 @@ pub struct SeriesPaint {
|
||||
/// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blend: Option<vectorcraft_color::BlendMode>,
|
||||
/// A gradient in [`Self::fill`] is placed relative to the unit box (0, 0)–(1, 1), and each mark takes it on its own
|
||||
/// geometric bounds. False: the gradient is in document coordinates, and every mark takes it unchanged.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub fill_unit_box: bool,
|
||||
/// A gradient in [`Self::stroke`] is placed relative to the unit box, as [`Self::fill_unit_box`], and each mark takes
|
||||
/// it on its stroke's box (the geometric bounds grown by half the weight).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub stroke_unit_box: bool,
|
||||
}
|
||||
|
||||
/// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! square to it: scaling by a side handle stretches it along its own width or height. Path
|
||||
//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
@@ -84,7 +86,35 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The turn (counter-clockwise degrees) from the boxes of art placed by a symbol instance's
|
||||
/// transform `xf` to the instance's own box at `angle`: 0 while the box turns with the transform,
|
||||
/// otherwise the change Reset Bounding Box made to it. Under a mirroring `xf` the instance's box can
|
||||
/// stand 180° from the one `xf` gives in one step ([`transformed_angle`] keeps the angle nearer the
|
||||
/// one before at each step); the turn is then 180°, so the art's boxes match the instance's.
|
||||
pub fn placed_turn(angle: f64, xf: Affine) -> f64 {
|
||||
let turn = normalize_deg(angle - transformed_angle(0.0, xf));
|
||||
if turn.is_finite() && turn.abs() > EPS_DEG { turn } else { 0.0 }
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Turns the box of this object, and of every object that turns with it ([`Node::transform`]:
|
||||
/// its children and the art of a linked opacity mask), by `by` degrees counter-clockwise.
|
||||
/// Layers have no box.
|
||||
pub fn turn_boxes(&mut self, by: f64) {
|
||||
if !self.is_layer() {
|
||||
let a = normalize_deg(self.bbox_angle + by);
|
||||
self.bbox_angle = if a.abs() <= EPS_DEG { 0.0 } else { a };
|
||||
}
|
||||
for c in self.children_mut().into_iter().flatten() {
|
||||
Arc::make_mut(c).turn_boxes(by);
|
||||
}
|
||||
if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut())
|
||||
&& m.linked
|
||||
{
|
||||
Arc::make_mut(&mut m.art).turn_boxes(by);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
|
||||
/// without changing it, symbol instances measured by their art from `symbols`
|
||||
/// ([`Node::geometric_bounds_with`]).
|
||||
@@ -185,6 +215,44 @@ mod tests {
|
||||
assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_turn_is_what_reset_bounding_box_took_off_the_instance() {
|
||||
let xf = Affine::translate((40.0, 10.0)) * rot(20.0) * Affine::scale_non_uniform(2.0, 0.5);
|
||||
// The box turns with the transform: no turn.
|
||||
assert_eq!(placed_turn(transformed_angle(0.0, xf), xf), 0.0);
|
||||
assert_eq!(placed_turn(0.0, Affine::scale(3.0)), 0.0);
|
||||
// Reset to 0: the placed art turns back by the 20°.
|
||||
assert!(close(placed_turn(0.0, xf), -20.0));
|
||||
// Mirrored: turned 170° and then flipped, the instance's box stands 180° from the box the
|
||||
// whole transform gives in one step, and the art turns the half turn to match it.
|
||||
let flip = Affine::scale_non_uniform(1.0, -1.0);
|
||||
let flipped = flip * rot(170.0);
|
||||
let stepwise = transformed_angle(transformed_angle(0.0, rot(170.0)), flip);
|
||||
assert!(close(stepwise, -170.0) && close(transformed_angle(0.0, flipped), 10.0));
|
||||
assert!(close(placed_turn(stepwise, flipped).abs(), 180.0));
|
||||
assert!(close(normalize_deg(placed_turn(0.0, flipped) + transformed_angle(0.0, flipped)), 0.0));
|
||||
// Degenerate and non-finite transforms turn nothing.
|
||||
assert_eq!(placed_turn(0.0, Affine::scale(0.0)), 0.0);
|
||||
assert_eq!(placed_turn(f64::NAN, xf), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_boxes_turns_children_and_linked_mask_art_but_not_layers() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
let mut g = Node::group(d.alloc_id(), vec![Arc::new(a.clone())]);
|
||||
g.bbox_angle = 170.0;
|
||||
g.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(a.clone(), true)));
|
||||
let mut layer = (*d.layers[0]).clone();
|
||||
layer.children_mut().unwrap().push(Arc::new(g));
|
||||
layer.turn_boxes(20.0);
|
||||
assert_eq!(layer.bbox_angle, 0.0);
|
||||
let g = &layer.children().unwrap()[0];
|
||||
assert!(close(g.bbox_angle, -170.0), "wraps to (-180, 180]");
|
||||
assert!(close(g.children().unwrap()[0].bbox_angle, 20.0));
|
||||
assert!(close(g.extras().mask.as_ref().unwrap().art.bbox_angle, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oriented_bounds_hug_a_rotated_rectangle() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
|
||||
@@ -614,6 +614,8 @@ impl NineSliceMap {
|
||||
/// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of
|
||||
/// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed
|
||||
/// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`].
|
||||
/// The clip groups and clip paths take the angle of the cell map that places them. The group
|
||||
/// and each copy keep `n`'s box angle, turned by the map that places them.
|
||||
///
|
||||
/// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own
|
||||
/// effects, after its last other visible effect, go on that group with its opacity, blending
|
||||
@@ -647,11 +649,15 @@ impl NineSliceMap {
|
||||
area.transform(local);
|
||||
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false });
|
||||
clip.appearance = Appearance::default().into();
|
||||
clip.bbox_angle = crate::orient::transformed_angle(0.0, local);
|
||||
let copy = transformed(n, local * self.natural);
|
||||
cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true })));
|
||||
let mut cell = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true });
|
||||
cell.bbox_angle = crate::orient::transformed_angle(0.0, local);
|
||||
cells.push(Arc::new(cell));
|
||||
}
|
||||
}
|
||||
let mut out = Node::group(n.id, cells);
|
||||
out.bbox_angle = crate::orient::transformed_angle(n.bbox_angle, center);
|
||||
out.visible = n.visible;
|
||||
out.locked = n.locked;
|
||||
out.name = n.name.clone();
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays
|
||||
//! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops
|
||||
//! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and
|
||||
//! applied wherever an instance is drawn or exported ([`dynamic_art`]).
|
||||
//! applied wherever an instance is drawn or exported ([`dynamic_art`]), and an instance's fill
|
||||
//! stains the art after them ([`instance_art`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
|
||||
use crate::{Node, NodeKind};
|
||||
use crate::{AppearanceItem, Node, NodeKind};
|
||||
|
||||
/// Deepest object an override may name: groups nested this deep in the symbol's art.
|
||||
pub const MAX_PATH_DEPTH: usize = 64;
|
||||
@@ -150,6 +151,52 @@ pub fn dynamic_art(art: &Node, inst: &Node) -> Node {
|
||||
a
|
||||
}
|
||||
|
||||
/// Mix every solid paint under `n` towards `color` by `amount` (0..1): the Symbol Stainer.
|
||||
pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
||||
let amount = amount.clamp(0.0, 1.0);
|
||||
for it in &mut n.appearance.items {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
for p in [&mut r.style.fill, &mut r.style.stroke] {
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
tint_node(Arc::make_mut(c), color, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stain instance `inst` puts on its symbol's art: its visible fill color and that fill's
|
||||
/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
|
||||
/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
|
||||
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
|
||||
let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
|
||||
Some((f.paint.color()?, f.opacity))
|
||||
}
|
||||
|
||||
/// A symbol's art for instance `inst`, as the canvas draws it: with the instance's own appearance
|
||||
/// changes to it ([`dynamic_art`]), stained by the instance's fill ([`stain`]).
|
||||
pub fn instance_art(art: &Node, inst: &Node) -> Node {
|
||||
let mut a = dynamic_art(art, inst);
|
||||
if let Some((c, amount)) = stain(inst) {
|
||||
tint_node(&mut a, &c, amount);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest.
|
||||
pub fn retain_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
|
||||
overrides.retain(|o| part(art, &o.path).is_some());
|
||||
@@ -159,7 +206,6 @@ pub fn retain_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Appearance, NodeId};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn square(id: u64) -> Arc<Node> {
|
||||
|
||||
@@ -523,13 +523,14 @@ fn color_mask_keys<'a>(n: &'a Node, out: &mut Vec<&'a str>) {
|
||||
|
||||
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
|
||||
/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art`
|
||||
/// when given, otherwise the symbol's art with the instance's own appearance changes.
|
||||
/// when given, otherwise the symbol's art with the instance's own appearance changes, stained by
|
||||
/// the instance's fill ([`vectorcraft_doc::symbol_overrides::instance_art`]).
|
||||
pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option<Node>) -> Option<Node> {
|
||||
let art;
|
||||
let src = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
|
||||
let symbol = symbol_art
|
||||
.or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)));
|
||||
.or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n)));
|
||||
art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?;
|
||||
&art
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints};
|
||||
use crate::reshape::{as_art, 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_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, outline_text, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -58,6 +58,7 @@ pub fn needs_bake(n: &Node) -> bool {
|
||||
|| has_geometry(&n.appearance.effects)
|
||||
|| n.projection().is_some()
|
||||
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|
||||
|| has_instance_row_raster(n)
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
|
||||
}
|
||||
|
||||
@@ -142,15 +143,19 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines, and so are
|
||||
// type whose own fills or strokes have geometry effects and images whose own fills or strokes
|
||||
// paint a shape ([`has_item_geometry`]). Type that Transform effects only move and copy stays
|
||||
// type, moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n)) {
|
||||
// paint a shape ([`has_item_geometry`]), and symbol instances whose active fill or stroke has a
|
||||
// raster effect, which the fills and strokes of their art then carry
|
||||
// ([`has_instance_row_raster`]). Type that Transform effects only move and copy stays type,
|
||||
// moved to each place.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n)) {
|
||||
if let Some(m) = moved_type_group(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// A symbol instance's art as the canvas draws it: with its own appearance changes, stained
|
||||
// by its fill.
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))
|
||||
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::instance_art(&s.art, n))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -59,6 +59,46 @@ fn object_effects_become_geometry_and_raster_effects_stay() {
|
||||
assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow");
|
||||
}
|
||||
|
||||
/// The fill colors of the paths under the layers of `d`, in paint order.
|
||||
fn path_fills(d: &Document) -> Vec<Color> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. })
|
||||
&& let Some(c) = n.appearance.fill_paint().color()
|
||||
{
|
||||
out.push(c);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A symbol instance half stained red is baked in its stained colors, as the canvas draws it:
|
||||
/// moved by a Transform effect, and recolored by a color adjustment (Invert Colors) after the
|
||||
/// stain.
|
||||
#[test]
|
||||
fn a_stained_instance_is_baked_in_its_stained_colors() {
|
||||
let blue = Color::rgb8(30, 90, 220);
|
||||
let art = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0));
|
||||
let mut inst = Node::new(NodeId(50), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: vectorcraft_geom::Affine::translate((20.0, 20.0)) });
|
||||
inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
|
||||
inst.appearance.items.retain(|i| i.is_fill());
|
||||
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut inst.appearance.items[0] {
|
||||
f.opacity = 0.5;
|
||||
}
|
||||
let stained = vectorcraft_doc::symbol_overrides::instance_art(&art, &inst).appearance.fill_paint().color().unwrap();
|
||||
assert_ne!(stained.to_hex(), blue.to_hex());
|
||||
let [r, g, b] = stained.to_rgb();
|
||||
let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b);
|
||||
for (id, params, want) in [("distort.transform", json!({"moveH": 30.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] {
|
||||
let mut n = inst.clone();
|
||||
n.appearance.effects.push(fx(id, params));
|
||||
let mut d = doc_with(n);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art.clone()) });
|
||||
let fills = path_fills(&bake_document(&d).unwrap());
|
||||
assert_eq!(fills.iter().map(Color::to_hex).collect::<Vec<_>>(), [want.to_hex()], "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baked_transform_moves_a_placed_gradient_with_the_path() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
|
||||
@@ -80,8 +80,9 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry};
|
||||
pub use reshape::{
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects,
|
||||
has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry,
|
||||
has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text,
|
||||
reshape,
|
||||
};
|
||||
pub use revolve::{
|
||||
CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in,
|
||||
|
||||
@@ -23,15 +23,22 @@ pub fn text_outliner() -> Outliner<'static> {
|
||||
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance,
|
||||
/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None`
|
||||
/// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and
|
||||
/// Pathfinder operations the symbol instances among their members.
|
||||
/// Pathfinder operations the symbol instances among their members. The group around the art takes
|
||||
/// the instance's box angle, and the art's boxes turn with it, as Break Link turns them.
|
||||
pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let doc = doc?;
|
||||
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
let art = doc.place_symbol_art(symbol, &art, *xf)?;
|
||||
Some(Node::group(n.id, vec![Arc::new(art)]))
|
||||
let mut art = doc.place_symbol_art(symbol, &art, *xf)?;
|
||||
let turn = vectorcraft_doc::orient::placed_turn(n.bbox_angle, *xf);
|
||||
if turn != 0.0 {
|
||||
art.turn_boxes(turn);
|
||||
}
|
||||
let mut out = Node::group(n.id, vec![Arc::new(art)]);
|
||||
out.bbox_angle = n.bbox_angle;
|
||||
Some(out)
|
||||
}
|
||||
_ => outline_text(n),
|
||||
}
|
||||
|
||||
@@ -162,15 +162,26 @@ fn active_stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> {
|
||||
n.appearance.stroke().filter(|s| s.visible && s.opacity > 0.0)
|
||||
}
|
||||
|
||||
/// The visible geometry effects in `fx`, in order.
|
||||
fn geometry_of(fx: &[Effect]) -> Vec<Effect> {
|
||||
fx.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect()
|
||||
/// Is `n` a symbol instance whose active fill or stroke has a visible raster effect (a shadow, glow,
|
||||
/// blur, feather or other painted effect, [`crate::is_raster`])? Its art takes that effect on each
|
||||
/// fill or stroke in it that paints ([`add_instance_row_effects`]), and draws it there.
|
||||
pub fn has_instance_row_raster(n: &Node) -> bool {
|
||||
let raster = |fx: &[Effect]| fx.iter().any(|e| e.visible && crate::is_raster(&e.id));
|
||||
matches!(n.kind, NodeKind::SymbolInstance { .. })
|
||||
&& (active_fill(n).is_some_and(|f| raster(&f.effects)) || active_stroke(n).is_some_and(|s| raster(&s.effects)))
|
||||
}
|
||||
|
||||
/// `art`, the art symbol instance `n` places, with the visible geometry effects of `n`'s active
|
||||
/// fill added after the effects of every fill in it that paints, and those of its active stroke
|
||||
/// after the effects of every stroke in it that paints ([`active_fill`], [`active_stroke`]). Each
|
||||
/// fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are
|
||||
/// The visible geometry and raster effects in `fx`, in order: those a symbol instance's active fill
|
||||
/// or stroke passes on to its art.
|
||||
fn row_effects_of(fx: &[Effect]) -> Vec<Effect> {
|
||||
fx.iter().filter(|e| e.visible && (is_geometry(&e.id) || crate::is_raster(&e.id))).cloned().collect()
|
||||
}
|
||||
|
||||
/// `art`, the art symbol instance `n` places, with the visible geometry and raster effects of
|
||||
/// `n`'s active fill added after the effects of every fill in it that paints, and those of its
|
||||
/// active stroke after the effects of every stroke in it that paints ([`active_fill`],
|
||||
/// [`active_stroke`]): a Drop Shadow on the instance's fill shadows each filled shape in the art.
|
||||
/// Each fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are
|
||||
/// reached are evaluated first (with the symbols of `doc`), so the effects reshape what they draw.
|
||||
/// Clip groups' clipping paths, guides and the art of opacity masks keep their shapes. A symbol
|
||||
/// instance in the art takes the effects on its own active fill or stroke, or on a new fill or
|
||||
@@ -180,8 +191,8 @@ fn geometry_of(fx: &[Effect]) -> Vec<Effect> {
|
||||
/// instance in the art takes them.
|
||||
fn add_instance_row_effects(doc: Option<&Document>, n: &Node, art: &mut Node) {
|
||||
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return };
|
||||
let fill = active_fill(n).map_or(vec![], |f| geometry_of(&f.effects));
|
||||
let stroke = active_stroke(n).map_or(vec![], |s| geometry_of(&s.effects));
|
||||
let fill = active_fill(n).map_or(vec![], |f| row_effects_of(&f.effects));
|
||||
let stroke = active_stroke(n).map_or(vec![], |s| row_effects_of(&s.effects));
|
||||
if !fill.is_empty() || !stroke.is_empty() {
|
||||
add_row_effects(doc, symbol, art, &fill, &stroke);
|
||||
}
|
||||
@@ -526,8 +537,8 @@ fn with_id(art: &Node, id: NodeId) -> Node {
|
||||
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
|
||||
/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), as
|
||||
/// the art its members draw when the instance has geometry effects of its own
|
||||
/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry effects of the
|
||||
/// instance's active fill and stroke ([`add_instance_row_effects`]).
|
||||
/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry and raster
|
||||
/// effects of the instance's active fill and stroke ([`add_instance_row_effects`]).
|
||||
/// `None` for other kinds (images, paths, containers), and for an instance whose art placed
|
||||
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
|
||||
/// `doc`'s symbols that its instances show toward its instance budget.
|
||||
@@ -709,12 +720,14 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping
|
||||
/// reshaped with it, and keep their effects from their first Convert to Shape or Outline Object
|
||||
/// on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
|
||||
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
|
||||
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
|
||||
/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is
|
||||
/// always reshaped. The fills and strokes in the art whose own Transform effects reach their
|
||||
/// appearance become objects of their own ([`transform_items`]).
|
||||
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts
|
||||
/// or a symbol instance whose active fill or stroke has a raster effect
|
||||
/// ([`has_instance_row_raster`]), or has no art (a symbol instance without `symbol_art`). A path
|
||||
/// with [`has_transform_art`] is always reshaped. The fills and strokes in the art whose own
|
||||
/// Transform effects reach their appearance become objects of their own ([`transform_items`]).
|
||||
pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if !has_transform_art(n) && ((geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n)) {
|
||||
let own = !geometry_effects(n).is_empty() || n.projection().is_some() || has_item_geometry(n) || has_instance_row_raster(n);
|
||||
if !has_transform_art(n) && (!own || !needs_outline(n)) {
|
||||
return None;
|
||||
}
|
||||
as_art(doc, n, symbol_art)
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2};
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, MeshedSolid, PlanarFace, View};
|
||||
|
||||
#[cfg(test)]
|
||||
use vectorcraft_doc::color::Color;
|
||||
@@ -656,7 +656,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many solids [`built_solids`] has built on this thread, for the tests.
|
||||
/// 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) };
|
||||
}
|
||||
|
||||
@@ -2039,31 +2039,35 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
|
||||
return Ok(out);
|
||||
}
|
||||
let (parts, bounds) = solid_parts(shapes, b);
|
||||
// The solids the art draws, past the limits of the object's solids together as it is
|
||||
// ([`built_solids`]), each built and its faces counted as the loop reaches it, then dropped.
|
||||
let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some()));
|
||||
// Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel
|
||||
// solid and its mesh are built as the loop reaches them.
|
||||
// Inflate lays out every puff at once (they share one eye). Each Inflate and Extrude & Bevel
|
||||
// solid is built once, as the loop reaches it: its facets decide whether the art draws it, as
|
||||
// [`built_solids`] does, and its faces before projection make its mesh.
|
||||
let mut inflated = (fx.id == INFLATE).then(|| {
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
|
||||
vectorcraft_three_d::inflate_meshed(&shapes, bounds, inflate_options(&p), view)
|
||||
});
|
||||
let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) };
|
||||
for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) {
|
||||
let mut kept = 0usize;
|
||||
for (item, member, area, rule, _, _) in &parts {
|
||||
let item: &AppearanceItem = item;
|
||||
if !drawn? {
|
||||
// A stroke the art leaves out is left out of the meshes too; Inflate's next puff
|
||||
// still belongs to the next part.
|
||||
if let Some(built) = inflated.as_mut() {
|
||||
built.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mesh = match inflated.as_mut() {
|
||||
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
|
||||
None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view),
|
||||
#[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(MeshedSolid::default())),
|
||||
None => vectorcraft_three_d::extrude_meshed(area, *rule, bounds, extrude, view),
|
||||
};
|
||||
match mesh {
|
||||
// A stroke the art leaves out is left out of the meshes too. The faces of the solids kept
|
||||
// count together against the object's limit.
|
||||
let solid = match built {
|
||||
Ok(solid) => solid,
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => continue,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
kept = kept.saturating_add(solid.facets.len());
|
||||
if kept > MAX_OBJECT_FACES {
|
||||
return Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string());
|
||||
}
|
||||
match solid.mesh() {
|
||||
Ok(mesh) => {
|
||||
let (paint, opacity, _) = paint_of(item, *member);
|
||||
keep(&mut out, mesh, mesh_color(&paint, opacity))?;
|
||||
|
||||
@@ -208,3 +208,151 @@ fn a_gradient_takes_the_average_of_its_stops() {
|
||||
let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5]));
|
||||
}
|
||||
|
||||
/// The meshes of Extrude & Bevel or Inflate `fx` on `shapes`, each built apart from the facets that
|
||||
/// decide which solids are drawn ([`built_solids`], then [`vectorcraft_three_d::extrude_mesh`] or
|
||||
/// [`vectorcraft_three_d::inflate_meshes`]), for comparing with [`meshes_for`].
|
||||
fn meshes_built_apart(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
let view = view_options(&p);
|
||||
let (parts, bounds) = solid_parts(shapes, union_bounds(shapes).unwrap());
|
||||
let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some()));
|
||||
let mut inflated = (fx.id == INFLATE).then(|| {
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
|
||||
});
|
||||
let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) };
|
||||
let mut out = vec![];
|
||||
for ((item, opacity, area, rule, _, _), drawn) in parts.iter().zip(drawn) {
|
||||
let item: &AppearanceItem = item;
|
||||
if !drawn? {
|
||||
if let Some(built) = inflated.as_mut() {
|
||||
built.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mesh = match inflated.as_mut() {
|
||||
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
|
||||
None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view),
|
||||
};
|
||||
match mesh {
|
||||
Ok(mesh) => {
|
||||
let (paint, opacity, _) = paint_of(item, *opacity);
|
||||
keep_mesh(&mut out, total, SolidMesh { mesh, color: mesh_color(&paint, opacity) }, limit)?;
|
||||
}
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The bits of every number in `meshes`, for comparing two exports exactly.
|
||||
type MeshBits = Vec<([u32; 4], Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>)>;
|
||||
|
||||
fn mesh_bits(meshes: &[SolidMesh]) -> MeshBits {
|
||||
meshes
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let (positions, normals) = (&m.mesh.positions, &m.mesh.normals);
|
||||
(
|
||||
m.color.map(f32::to_bits),
|
||||
positions.iter().map(|p| p.map(f64::to_bits)).collect(),
|
||||
normals.iter().map(|n| n.map(f64::to_bits)).collect(),
|
||||
m.mesh.triangles.clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A five-pointed star about `(x, 150)` with curved edges, filled red and stroked black.
|
||||
fn star_node(id: u64, x: f64) -> Node {
|
||||
let mut b = BezPath::new();
|
||||
for k in 0..10 {
|
||||
let (s, c) = (k as f64 * std::f64::consts::PI / 5.0).sin_cos();
|
||||
let r = if k % 2 == 0 { 60.0 } else { 25.0 };
|
||||
let p = (x + r * c, 150.0 + r * s);
|
||||
if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 4.0, p.1), (p.0, p.1 + 4.0), p) }
|
||||
}
|
||||
b.close_path();
|
||||
Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 6.0))
|
||||
}
|
||||
|
||||
/// The shapes `meshes_for` builds from: one node's, or each of several.
|
||||
fn shapes_of(nodes: &[&Node]) -> Vec<Profile> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|n| Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Building each Extrude & Bevel and Inflate solid once exports the same meshes, to the bit, as
|
||||
/// building its facets and its mesh apart, within and past the triangle budget.
|
||||
#[test]
|
||||
fn one_build_exports_the_meshes_built_apart() {
|
||||
let mut second_stroke = square_node(3, 400.0);
|
||||
if let Some(stroke) = second_stroke.appearance.items.get(1).cloned() {
|
||||
second_stroke.appearance.items.push(stroke);
|
||||
}
|
||||
let (star, square, other) = (star_node(1, 100.0), square_node(2, 250.0), star_node(4, 600.0));
|
||||
let cases = [
|
||||
(EXTRUDE, json!({})),
|
||||
(EXTRUDE, json!({ "bevel": "rounded", "bevelWidth": 6.0, "bevelHeight": 5.0, "twist": 60.0, "taper": 70.0 })),
|
||||
(EXTRUDE, json!({ "bevel": "cove", "bevelInside": true, "bevelBothSides": true, "cap": false, "perspectiveAngle": 50.0 })),
|
||||
(EXTRUDE, json!({ "depth": 0.0 })),
|
||||
(CLASSIC_EXTRUDE, json!({ "bevel": "classic", "bevelHeight": 8.0 })),
|
||||
(INFLATE, json!({})),
|
||||
(INFLATE, json!({ "depth": 30.0, "bothSides": true, "volume": 80.0 })),
|
||||
(INFLATE, json!({ "depth": 40.0, "twist": 360.0, "taper": 70.0, "cap": false })),
|
||||
(INFLATE, json!({ "depth": 0.1, "twist": 0.1, "bothSides": true })),
|
||||
];
|
||||
let sets: [Vec<&Node>; 3] = [vec![&star], vec![&second_stroke], vec![&star, &square, &other]];
|
||||
for (id, params) in cases {
|
||||
let effect = fx(id, params.clone());
|
||||
for nodes in &sets {
|
||||
let shapes = shapes_of(nodes);
|
||||
let (mut once, mut apart) = (0, 0);
|
||||
let built = meshes_for(&effect, &shapes, &mut once, MAX_MESH_TRIANGLES).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
|
||||
let reference = meshes_built_apart(&effect, &shapes, &mut apart, MAX_MESH_TRIANGLES).unwrap();
|
||||
assert!(!reference.is_empty());
|
||||
assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id} {params}");
|
||||
assert_eq!(once, apart);
|
||||
// Past the budget, at the same mesh, with the same count.
|
||||
let limit = once - 1;
|
||||
let (mut once, mut apart) = (0, 0);
|
||||
let built = meshes_for(&effect, &shapes, &mut once, limit);
|
||||
assert!(built.is_err(), "{id} {params}");
|
||||
assert_eq!(built.err(), meshes_built_apart(&effect, &shapes, &mut apart, limit).err(), "{id} {params}");
|
||||
assert_eq!(once, apart, "{id} {params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke whose solid is past the limits is left out of the export and the fill's mesh exports,
|
||||
/// as when the facets and the mesh are built apart.
|
||||
#[test]
|
||||
fn a_stroke_past_the_limits_is_left_out_of_the_export() {
|
||||
// 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline.
|
||||
let points: Vec<Point> = (0..40_000)
|
||||
.map(|k| {
|
||||
let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos();
|
||||
let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 };
|
||||
Point::new(20_000.0 + r * c, 20_000.0 + r * s)
|
||||
})
|
||||
.collect();
|
||||
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
|
||||
let n = Node::path(NodeId(1), path, Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 2.0));
|
||||
for id in [EXTRUDE, INFLATE] {
|
||||
// Without caps: a cap of 40,000 corners takes more work to cut into triangles than an
|
||||
// export allows.
|
||||
let effect = fx(id, json!({ "cap": false }));
|
||||
let shapes = shapes_of(&[&n]);
|
||||
// The fill's mesh alone has more triangles than an export takes.
|
||||
let (mut once, mut apart) = (0, 0);
|
||||
let built = meshes_for(&effect, &shapes, &mut once, usize::MAX).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert_eq!(built.len(), 1, "{id}: the fill's mesh alone");
|
||||
assert!(close(built[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}");
|
||||
let reference = meshes_built_apart(&effect, &shapes, &mut apart, usize::MAX).unwrap();
|
||||
assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1570,6 +1570,27 @@ fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
|
||||
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
|
||||
}
|
||||
|
||||
/// A symbol instance given to Pathfinder and compound shapes as its art: the group takes the
|
||||
/// instance's box angle and the art turns with it, turned with the instance or reset to 0.
|
||||
#[test]
|
||||
fn outline_object_turns_an_instances_art_with_its_box() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::orient::transformed_angle;
|
||||
use vectorcraft_doc::{Appearance, Document, NodeId, Symbol};
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
d.symbols.push(Symbol { dynamic: false, name: "s".into(), art: Arc::new(Node::path(NodeId(0), square(), Appearance::default())) });
|
||||
let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(0.35);
|
||||
for box_angle in [transformed_angle(0.0, xf), 0.0] {
|
||||
let mut inst = Node::new(NodeId(3), NodeKind::SymbolInstance { symbol: "s".into(), xf });
|
||||
inst.bbox_angle = box_angle;
|
||||
let out = outline_object(Some(&d), &inst).unwrap();
|
||||
let mut angles = vec![];
|
||||
out.walk(&mut |c| angles.push(c.bbox_angle));
|
||||
assert!(angles.len() == 2 && angles.iter().all(|a| close(*a, box_angle, 1e-9)), "{box_angle}: {angles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_shape_sizes() {
|
||||
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
|
||||
|
||||
@@ -440,6 +440,15 @@ pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
|
||||
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
}
|
||||
}
|
||||
/// The whole number of 0 or more that `p` gives for `key`. A missing key or null is "missing integer
|
||||
/// `{key}`"; any other value, such as `"3"`, `2.5`, `-1` or `true`, is "`{key}` must be a whole
|
||||
/// number", as [`object_param`] words it.
|
||||
pub(crate) fn u64_req(p: &Value, key: &str, cmd: &str) -> Result<u64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Err(bad(cmd, format!("missing integer `{key}`"))),
|
||||
Some(v) => v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))),
|
||||
}
|
||||
}
|
||||
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other
|
||||
/// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}".
|
||||
pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
|
||||
@@ -550,6 +559,15 @@ pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<No
|
||||
pub(crate) fn index(v: u64) -> usize {
|
||||
usize::try_from(v).unwrap_or(usize::MAX)
|
||||
}
|
||||
/// The whole number of 0 or more that `p` gives for `key`, read with [`index`], or `None` when `p`
|
||||
/// leaves it out or gives null. Any other value, such as `"3"`, `2.5`, `-1` or `true`, is an error
|
||||
/// under `cmd`: "`{key}` must be a whole number".
|
||||
pub(crate) fn index_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(|n| Some(index(n))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))),
|
||||
}
|
||||
}
|
||||
/// Validate all explicit targets before a command changes the document or selection.
|
||||
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
|
||||
checked_nodes_param(s, p, key, command, false)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{LiveShape, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes};
|
||||
@@ -16,9 +18,25 @@ use vectorcraft_tools::addons::box3d::CustomShape;
|
||||
|
||||
use super::super::create::insert_look;
|
||||
use super::super::{
|
||||
CommandSpec, Result, Session, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_selection, str_param, targets,
|
||||
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_selection, number_or, str_param,
|
||||
targets,
|
||||
};
|
||||
|
||||
/// The sides of a polygon and the points of a star: whole numbers in this range.
|
||||
const COUNTS: RangeInclusive<f64> = 3.0..=1000.0;
|
||||
/// `rounded` and `randomized` of a polygon or star.
|
||||
const ROUNDING: RangeInclusive<f64> = -10.0..=10.0;
|
||||
/// A star's inner radius as a fraction of its outer radius.
|
||||
const SPOKE_RATIO: RangeInclusive<f64> = 0.01..=1.0;
|
||||
/// A spiral's `turns`.
|
||||
const TURNS: RangeInclusive<f64> = 0.01..=1024.0;
|
||||
/// A spiral's `divergence`.
|
||||
const DIVERGENCE: RangeInclusive<f64> = 0.0..=1000.0;
|
||||
/// A spiral's `innerRadius`, as a fraction of its radius.
|
||||
const INNER_RADIUS: RangeInclusive<f64> = 0.0..=0.999;
|
||||
/// The `arcMode` names; `slice` and `open` are also read as `pie` and `arc`.
|
||||
const ARC_MODES: [&str; 4] = ["whole", "pie", "chord", "arc"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -26,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rectangle",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
"{x, y, width, height: pt (0 or more), radius?: pt (0 or more), radii?: [tl,tr,br,bl] (each 0 or more), startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
rectangle
|
||||
),
|
||||
@@ -35,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Ellipse",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
"{x, y, width, height: pt (0 or more), startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
ellipse
|
||||
),
|
||||
@@ -44,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Polygon",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}",
|
||||
"{cx, cy, radius: pt (0 or more), sides=6: a whole number 3 to 1000, rounded=0, randomized=0: -10 to 10, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}",
|
||||
has_doc,
|
||||
polygon
|
||||
),
|
||||
@@ -53,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Star",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}",
|
||||
"{cx, cy, radius1: pt (0 or more), radius2?: pt (0.01 to 1 times radius1; default half of it), points=5: a whole number 3 to 1000, rounded=0, randomized=0: -10 to 10, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}",
|
||||
has_doc,
|
||||
star
|
||||
),
|
||||
@@ -62,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Spiral",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius, turns=3, divergence=1, innerRadius=0, clockwise=true} → {id}",
|
||||
"{cx, cy, radius: pt (0 or more), turns=3: 0.01 to 1024, divergence=1: 0 to 1000, innerRadius=0: 0 to 0.999 of radius, clockwise=true} → {id}",
|
||||
has_doc,
|
||||
spiral
|
||||
),
|
||||
@@ -71,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"3D Box",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}",
|
||||
"{x, y, width, height: pt (0 or more), angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}",
|
||||
has_doc,
|
||||
box3d
|
||||
),
|
||||
@@ -80,33 +98,96 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Configure Shape",
|
||||
[],
|
||||
None,
|
||||
"{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}",
|
||||
"{ids?, radius?: pt (0 or more; fitted to half each rectangle's shorter side), radii?: [tl,tr,br,bl] (each 0 or more), startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?: a whole number 3 to 1000, isStar?, spokeRatio?: 0.01 to 1, rounded?, randomized?: -10 to 10, turns?: 0.01 to 1024, divergence?: 0 to 1000, innerRadius?: 0 to 0.999, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} an omitted or null value keeps each shape's setting → {updated}",
|
||||
has_selection,
|
||||
configure
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn parse_arc_params(p: &Value) -> ((f64, f64), shapes::EllipseMode) {
|
||||
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. A number
|
||||
/// outside `range` is an error that names `key` and the range.
|
||||
fn ranged(p: &Value, key: &str, default: f64, range: &RangeInclusive<f64>, cmd: &str) -> Result<f64> {
|
||||
let x = number_or(p, key, default, cmd)?;
|
||||
if !range.contains(&x) {
|
||||
let (min, max) = (range.start(), range.end());
|
||||
return Err(bad(cmd, format!("`{key}` must be between {min} and {max} (got {x})")));
|
||||
}
|
||||
Ok(x)
|
||||
}
|
||||
|
||||
/// The number of sides or points `p` gives for `key`, or `default` when `p` leaves it out or gives
|
||||
/// null. A fraction or a number outside [`COUNTS`] is an error.
|
||||
fn count(p: &Value, key: &str, default: u32, cmd: &str) -> Result<u32> {
|
||||
let x = number_or(p, key, f64::from(default), cmd)?;
|
||||
if x.fract() != 0.0 || !COUNTS.contains(&x) {
|
||||
let (min, max) = (COUNTS.start(), COUNTS.end());
|
||||
return Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})")));
|
||||
}
|
||||
Ok(x as u32)
|
||||
}
|
||||
|
||||
/// The size `p` gives for `key`: a number that is not negative.
|
||||
fn size(p: &Value, key: &str, cmd: &str) -> Result<f64> {
|
||||
not_negative(f64_req(p, key, cmd)?, key, cmd)
|
||||
}
|
||||
|
||||
/// The size `p` gives for `key`, or `default` when `p` leaves it out or gives null.
|
||||
fn size_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
|
||||
not_negative(number_or(p, key, default, cmd)?, key, cmd)
|
||||
}
|
||||
|
||||
fn not_negative(x: f64, key: &str, cmd: &str) -> Result<f64> {
|
||||
if x < 0.0 {
|
||||
return Err(bad(cmd, format!("`{key}` must be a number not below 0")));
|
||||
}
|
||||
Ok(x)
|
||||
}
|
||||
|
||||
/// The four corner radii `p` gives as `radii` ([tl, tr, br, bl]), or `None` when `p` leaves it out
|
||||
/// or gives null. Anything but four numbers that are not negative is an error.
|
||||
fn radii_param(p: &Value, cmd: &str) -> Result<Option<[f64; 4]>> {
|
||||
let key = "radii";
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
let four = v.as_array().filter(|a| a.len() == 4).and_then(|a| {
|
||||
let mut out = [0.0; 4];
|
||||
for (slot, item) in out.iter_mut().zip(a) {
|
||||
*slot = item.as_f64().filter(|r| *r >= 0.0)?;
|
||||
}
|
||||
Some(out)
|
||||
});
|
||||
four.map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be four numbers not below 0: [tl, tr, br, bl]")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode `p` gives as `arcMode`, or `None` when `p` leaves it out or gives null. Any other
|
||||
/// value than one of [`ARC_MODES`] (or `slice` or `open`) is an error.
|
||||
fn arc_mode(p: &Value, cmd: &str) -> Result<Option<shapes::EllipseMode>> {
|
||||
let key = "arcMode";
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_str().and_then(parse_mode).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be one of {}", ARC_MODES.join(", ")))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_arc_params(p: &Value, cmd: &str) -> Result<((f64, f64), shapes::EllipseMode)> {
|
||||
let start = f64_or(p, "startAngle", 0.0);
|
||||
let end = f64_or(p, "endAngle", 360.0);
|
||||
let mode = match str_param(p, "arcMode") {
|
||||
Some("pie" | "slice") => shapes::EllipseMode::Slice,
|
||||
Some("chord") => shapes::EllipseMode::Chord,
|
||||
Some("arc" | "open") => shapes::EllipseMode::Arc,
|
||||
_ => shapes::EllipseMode::Whole,
|
||||
};
|
||||
let mode = arc_mode(p, cmd)?.unwrap_or(shapes::EllipseMode::Whole);
|
||||
let span = (end - start).rem_euclid(360.0);
|
||||
let pie = if mode == shapes::EllipseMode::Whole && (span < 1e-6 || (span - 360.0).abs() < 1e-6) { (0.0, 360.0) } else { (start, end) };
|
||||
(pie, mode)
|
||||
Ok((pie, mode))
|
||||
}
|
||||
|
||||
fn rect_of(p: &Value, c: &str) -> Result<Rect> {
|
||||
let x = f64_req(p, "x", c)?;
|
||||
let y = f64_req(p, "y", c)?;
|
||||
let w = f64_req(p, "width", c)?;
|
||||
let h = f64_req(p, "height", c)?;
|
||||
Ok(Rect::new(x, y, x + w.abs(), y + h.abs()).abs())
|
||||
let w = size(p, "width", c)?;
|
||||
let h = size(p, "height", c)?;
|
||||
Ok(Rect::new(x, y, x + w, y + h))
|
||||
}
|
||||
|
||||
fn rect_xf(r: Rect, rot: f64) -> Affine {
|
||||
@@ -141,18 +222,9 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "addon.shapes.rectangle";
|
||||
check_numbers(p, &["radius", "startAngle", "endAngle", "rotation"], c)?;
|
||||
let r = rect_of(p, c)?;
|
||||
let radius = f64_or(p, "radius", 0.0).max(0.0);
|
||||
let mut radii = [radius; 4];
|
||||
if let Some(arr) = p.get("radii").and_then(Value::as_array)
|
||||
&& arr.len() == 4
|
||||
{
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
if let Some(val) = item.as_f64().filter(|n| n.is_finite()) {
|
||||
radii[i] = val.max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (pie, arc_mode) = parse_arc_params(p);
|
||||
let radius = size_or(p, "radius", 0.0, c)?;
|
||||
let radii = radii_param(p, c)?.unwrap_or([radius; 4]);
|
||||
let (pie, arc_mode) = parse_arc_params(p, c)?;
|
||||
let xf = rect_xf(r, f64_or(p, "rotation", 0.0));
|
||||
let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf };
|
||||
let cs = CustomShape::Rectangle { w: r.width(), h: r.height(), radii, kinds: Default::default(), radii_relative: false, xf, pie, arc_mode };
|
||||
@@ -164,7 +236,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "addon.shapes.ellipse";
|
||||
check_numbers(p, &["startAngle", "endAngle", "rotation"], c)?;
|
||||
let r = rect_of(p, c)?;
|
||||
let (pie, arc_mode) = parse_arc_params(p);
|
||||
let (pie, arc_mode) = parse_arc_params(p, c)?;
|
||||
let xf = rect_xf(r, f64_or(p, "rotation", 0.0));
|
||||
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie, xf };
|
||||
let cs = CustomShape::Ellipse { w: r.width(), h: r.height(), pie, arc_mode, xf };
|
||||
@@ -174,13 +246,13 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "addon.shapes.polygon";
|
||||
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
|
||||
let r = f64_req(p, "radius", c)?.abs();
|
||||
let r = size(p, "radius", c)?;
|
||||
check_numbers(p, &["sides", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?;
|
||||
let sides = (f64_or(p, "sides", 6.0).round() as i64).clamp(3, 1000) as u32;
|
||||
let sides = count(p, "sides", 6, c)?;
|
||||
let rot = f64_or(p, "rotation", 0.0);
|
||||
let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0);
|
||||
let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0);
|
||||
let (pie, arc_mode) = parse_arc_params(p);
|
||||
let rounded = ranged(p, "rounded", 0.0, &ROUNDING, c)?;
|
||||
let randomized = ranged(p, "randomized", 0.0, &ROUNDING, c)?;
|
||||
let (pie, arc_mode) = parse_arc_params(p, c)?;
|
||||
let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot);
|
||||
let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false };
|
||||
let cs = CustomShape::Polygon {
|
||||
@@ -202,16 +274,17 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn star(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "addon.shapes.star";
|
||||
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
|
||||
let r1 = f64_req(p, "radius1", c)?.abs();
|
||||
let r1 = size(p, "radius1", c)?;
|
||||
check_numbers(p, &["radius2", "points", "rotation", "rounded", "randomized", "startAngle", "endAngle"], c)?;
|
||||
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
|
||||
let points = (f64_or(p, "points", 5.0).round() as i64).clamp(3, 1000) as u32;
|
||||
// The inner radius is a fraction of the outer one, in `SPOKE_RATIO`.
|
||||
let r2 = ranged(p, "radius2", r1 / 2.0, &(r1 * SPOKE_RATIO.start()..=r1 * SPOKE_RATIO.end()), c)?;
|
||||
let points = count(p, "points", 5, c)?;
|
||||
let rot = f64_or(p, "rotation", 0.0);
|
||||
let rounded = f64_or(p, "rounded", 0.0).clamp(-10.0, 10.0);
|
||||
let randomized = f64_or(p, "randomized", 0.0).clamp(-10.0, 10.0);
|
||||
let (pie, arc_mode) = parse_arc_params(p);
|
||||
let rounded = ranged(p, "rounded", 0.0, &ROUNDING, c)?;
|
||||
let randomized = ranged(p, "randomized", 0.0, &ROUNDING, c)?;
|
||||
let (pie, arc_mode) = parse_arc_params(p, c)?;
|
||||
let xf = Affine::translate(center.to_vec2()) * vectorcraft_geom::rotate_deg(rot);
|
||||
let ratio = if r1 > 1e-9 { (r2 / r1).clamp(0.01, 1.0) } else { 0.5 };
|
||||
let ratio = if r1 > 1e-9 { (r2 / r1).clamp(*SPOKE_RATIO.start(), *SPOKE_RATIO.end()) } else { 0.5 };
|
||||
let cs = CustomShape::Polygon {
|
||||
radius: r1,
|
||||
sides: points,
|
||||
@@ -231,12 +304,12 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "addon.shapes.spiral";
|
||||
let center = Point::new(f64_req(p, "cx", c)?, f64_req(p, "cy", c)?);
|
||||
let r = f64_req(p, "radius", c)?.abs();
|
||||
let r = size(p, "radius", c)?;
|
||||
check_numbers(p, &["turns", "divergence", "innerRadius"], c)?;
|
||||
check_switches(p, &["clockwise"], c)?;
|
||||
let turns = f64_or(p, "turns", 3.0).clamp(0.01, 1024.0);
|
||||
let divergence = f64_or(p, "divergence", 1.0).clamp(0.0, 1000.0);
|
||||
let inner_radius = f64_or(p, "innerRadius", 0.0).clamp(0.0, 0.999);
|
||||
let turns = ranged(p, "turns", 3.0, &TURNS, c)?;
|
||||
let divergence = ranged(p, "divergence", 1.0, &DIVERGENCE, c)?;
|
||||
let inner_radius = ranged(p, "innerRadius", 0.0, &INNER_RADIUS, c)?;
|
||||
let clockwise = bool_or(p, "clockwise", true);
|
||||
let cs = CustomShape::Spiral { center, radius: r, turns, divergence, inner_radius, clockwise };
|
||||
insert_custom(s, "Spiral", None, cs)
|
||||
@@ -299,24 +372,54 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c,
|
||||
)?;
|
||||
check_switches(p, &["isStar", "parallelX", "parallelY", "parallelZ", "reset"], c)?;
|
||||
check_configure(p, c)?;
|
||||
let ids = targets(s, p)?;
|
||||
let reset = bool_or(p, "reset", false);
|
||||
let spiral_hint = p.get("turns").is_some() || p.get("divergence").is_some() || p.get("innerRadius").is_some();
|
||||
// The shapes the params change, worked out first so that a call that changes nothing records
|
||||
// no undo step.
|
||||
let changes: Vec<(NodeId, CustomShape)> = {
|
||||
let doc = &s.doc()?.doc;
|
||||
ids.into_iter()
|
||||
.filter_map(|id| {
|
||||
let n = doc.node(id)?;
|
||||
let before = CustomShape::read_for_update(n, spiral_hint)?;
|
||||
let mut cs = before.clone();
|
||||
let stored = CustomShape::read(n).is_some();
|
||||
(apply_shape_params(&mut cs, p, reset) && (cs != before || !stored)).then_some((id, cs))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if changes.is_empty() {
|
||||
return Ok(json!({ "updated": 0 }));
|
||||
}
|
||||
let updated = s.edit("Live Shape", |d, _| {
|
||||
let mut count = 0usize;
|
||||
for id in ids {
|
||||
let Some(n) = d.node_mut(id) else { continue };
|
||||
let Some(mut cs) = CustomShape::read_for_update(n, spiral_hint) else { continue };
|
||||
if apply_shape_params(&mut cs, p, reset) {
|
||||
cs.write_to_node(n);
|
||||
count += 1;
|
||||
}
|
||||
for (id, cs) in &changes {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
cs.write_to_node(n);
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
})?;
|
||||
Ok(json!({ "updated": updated }))
|
||||
}
|
||||
|
||||
/// Error unless every bounded value `p` gives is in its range, before any shape changes.
|
||||
fn check_configure(p: &Value, c: &str) -> Result<()> {
|
||||
size_or(p, "radius", 0.0, c)?;
|
||||
radii_param(p, c)?;
|
||||
arc_mode(p, c)?;
|
||||
count(p, "sides", 6, c)?;
|
||||
ranged(p, "spokeRatio", 0.5, &SPOKE_RATIO, c)?;
|
||||
ranged(p, "rounded", 0.0, &ROUNDING, c)?;
|
||||
ranged(p, "randomized", 0.0, &ROUNDING, c)?;
|
||||
ranged(p, "turns", 3.0, &TURNS, c)?;
|
||||
ranged(p, "divergence", 1.0, &DIVERGENCE, c)?;
|
||||
ranged(p, "innerRadius", 0.0, &INNER_RADIUS, c)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn num_param(p: &Value, k: &str) -> Option<f64> {
|
||||
p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite())
|
||||
}
|
||||
@@ -331,8 +434,8 @@ fn apply_arc_params(p: &Value, pie: &mut (f64, f64), arc_mode: &mut shapes::Elli
|
||||
pie.1 = a1.rem_euclid(360.0);
|
||||
changed = true;
|
||||
}
|
||||
if let Some(m) = str_param(p, "arcMode") {
|
||||
*arc_mode = parse_mode(m);
|
||||
if let Some(m) = str_param(p, "arcMode").and_then(parse_mode) {
|
||||
*arc_mode = m;
|
||||
if *arc_mode == shapes::EllipseMode::Whole {
|
||||
*pie = (0.0, 360.0);
|
||||
} else if (pie.1 - pie.0).abs() < 1e-6 || ((pie.1 - pie.0).abs() - 360.0).abs() < 1e-6 {
|
||||
@@ -351,19 +454,14 @@ fn apply_rect_params(p: &Value, reset: bool, max_r: f64, radii: &mut [f64; 4], p
|
||||
*arc_mode = shapes::EllipseMode::Whole;
|
||||
changed = true;
|
||||
}
|
||||
// A radius larger than half the rectangle's shorter side is fitted to that size.
|
||||
if let Some(r) = num_param(p, "radius") {
|
||||
*radii = [r.clamp(0.0, max_r); 4];
|
||||
changed = true;
|
||||
}
|
||||
if let Some(arr) = p.get("radii").and_then(Value::as_array)
|
||||
&& arr.len() == 4
|
||||
{
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
if let Some(r) = v.as_f64().filter(|x| x.is_finite()) {
|
||||
radii[i] = r.clamp(0.0, max_r);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if let Ok(Some(four)) = radii_param(p, "addon.shapes.configure") {
|
||||
*radii = four.map(|r| r.clamp(0.0, max_r));
|
||||
changed = true;
|
||||
}
|
||||
changed | apply_arc_params(p, pie, arc_mode)
|
||||
}
|
||||
@@ -378,8 +476,9 @@ fn apply_poly_params(p: &Value, reset: bool, sides: &mut u32, spoke_ratio: &mut
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if let Some(n_sides) = p.get("sides").and_then(Value::as_u64) {
|
||||
*sides = (n_sides as u32).clamp(3, 1000);
|
||||
// `check_configure` has refused values outside their ranges.
|
||||
if let Some(n_sides) = num_param(p, "sides") {
|
||||
*sides = n_sides as u32;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(star) = p.get("isStar").and_then(Value::as_bool) {
|
||||
@@ -387,15 +486,15 @@ fn apply_poly_params(p: &Value, reset: bool, sides: &mut u32, spoke_ratio: &mut
|
||||
changed = true;
|
||||
}
|
||||
if let Some(sr) = num_param(p, "spokeRatio") {
|
||||
*spoke_ratio = Some(sr.clamp(0.01, 1.0));
|
||||
*spoke_ratio = Some(sr);
|
||||
changed = true;
|
||||
}
|
||||
if let Some(rnd) = num_param(p, "rounded") {
|
||||
*rounded = rnd.clamp(-10.0, 10.0);
|
||||
*rounded = rnd;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(rand) = num_param(p, "randomized") {
|
||||
*randomized = rand.clamp(-10.0, 10.0);
|
||||
*randomized = rand;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
@@ -429,15 +528,15 @@ fn apply_shape_params(cs: &mut CustomShape, p: &Value, reset: bool) -> bool {
|
||||
changed = true;
|
||||
}
|
||||
if let Some(t) = num_param(p, "turns") {
|
||||
*turns = t.clamp(0.01, 1024.0);
|
||||
*turns = t;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(div) = num_param(p, "divergence") {
|
||||
*divergence = div.clamp(0.0, 1000.0);
|
||||
*divergence = div;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(ir) = num_param(p, "innerRadius") {
|
||||
*inner_radius = ir.clamp(0.0, 0.999);
|
||||
*inner_radius = ir;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
@@ -470,11 +569,12 @@ fn apply_shape_params(cs: &mut CustomShape, p: &Value, reset: bool) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mode(m: &str) -> shapes::EllipseMode {
|
||||
fn parse_mode(m: &str) -> Option<shapes::EllipseMode> {
|
||||
match m {
|
||||
"pie" | "slice" => shapes::EllipseMode::Slice,
|
||||
"chord" => shapes::EllipseMode::Chord,
|
||||
"arc" | "open" => shapes::EllipseMode::Arc,
|
||||
_ => shapes::EllipseMode::Whole,
|
||||
"whole" => Some(shapes::EllipseMode::Whole),
|
||||
"pie" | "slice" => Some(shapes::EllipseMode::Slice),
|
||||
"chord" => Some(shapes::EllipseMode::Chord),
|
||||
"arc" | "open" => Some(shapes::EllipseMode::Arc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,3 +135,169 @@ fn addon_shapes_commands_and_format_roundtrip_preserve_paths() {
|
||||
assert_eq!(CustomShape::read(a), CustomShape::read(b));
|
||||
}
|
||||
}
|
||||
|
||||
fn id_of(s: &mut Session, cmd: &str, p: serde_json::Value) -> NodeId {
|
||||
NodeId(s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn shape_of(s: &Session, id: NodeId) -> CustomShape {
|
||||
CustomShape::read(s.doc().unwrap().doc.node(id).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p`, which must fail with `msg` and leave the document and its history as they
|
||||
/// were.
|
||||
fn refused(s: &mut Session, cmd: &str, p: serde_json::Value, msg: &str) {
|
||||
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let e = s.execute(cmd, &p).map(|v| v.to_string()).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{cmd} {p}: {e}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {p}");
|
||||
assert_eq!(vectorcraft_format::save(&s.doc().unwrap().doc, false), saved, "{cmd} {p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_and_star_refuse_counts_that_are_not_whole_numbers_from_3_to_1000() {
|
||||
let mut s = new_session();
|
||||
for n in [json!(2.5), json!(2), json!(0), json!(-6), json!(1000.5), json!(5000), json!(4_294_967_299u64)] {
|
||||
let x = n.as_f64().unwrap();
|
||||
let p = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": n});
|
||||
refused(&mut s, "addon.shapes.polygon", p, &format!("`sides` must be a whole number between 3 and 1000 (got {x})"));
|
||||
let p = json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": n});
|
||||
refused(&mut s, "addon.shapes.star", p, &format!("`points` must be a whole number between 3 and 1000 (got {x})"));
|
||||
}
|
||||
refused(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": "6"}), "`sides` must be a number");
|
||||
refused(&mut s, "addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": [5]}), "`points` must be a number");
|
||||
|
||||
// The ends of the range are taken as given, and an omitted or null count is the default.
|
||||
for (n, want) in [(json!(3), 3), (json!(1000), 1000), (json!(7.0), 7), (json!(null), 6)] {
|
||||
let id = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0, "sides": n}));
|
||||
assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides, spoke_ratio: None, .. } if sides == want), "{n}");
|
||||
}
|
||||
let id = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": 10.0}));
|
||||
assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides: 6, .. }));
|
||||
for (n, want) in [(json!(3), 3), (json!(1000), 1000), (json!(null), 5)] {
|
||||
let id = id_of(&mut s, "addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0, "points": n}));
|
||||
assert!(matches!(shape_of(&s, id), CustomShape::Polygon { sides, spoke_ratio: Some(_), .. } if sides == want), "{n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_refuse_numbers_outside_their_ranges() {
|
||||
let mut s = new_session();
|
||||
let poly = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0});
|
||||
let star = json!({"cx": 0.0, "cy": 0.0, "radius1": 10.0});
|
||||
let spiral = json!({"cx": 0.0, "cy": 0.0, "radius": 10.0});
|
||||
let with = |base: &serde_json::Value, key: &str, v: f64| {
|
||||
let mut p = base.clone();
|
||||
p[key] = json!(v);
|
||||
p
|
||||
};
|
||||
for (cmd, base, key, bad, good) in [
|
||||
("addon.shapes.polygon", &poly, "rounded", [-10.5, 10.5], [-10.0, 10.0]),
|
||||
("addon.shapes.polygon", &poly, "randomized", [-11.0, 10.01], [-10.0, 10.0]),
|
||||
("addon.shapes.star", &star, "rounded", [-10.5, 10.5], [-10.0, 10.0]),
|
||||
("addon.shapes.star", &star, "randomized", [-11.0, 10.01], [-10.0, 10.0]),
|
||||
("addon.shapes.star", &star, "radius2", [0.05, 11.0], [0.1, 10.0]),
|
||||
("addon.shapes.spiral", &spiral, "turns", [0.0, 1024.5], [0.01, 1024.0]),
|
||||
("addon.shapes.spiral", &spiral, "divergence", [-0.1, 1000.5], [0.0, 1000.0]),
|
||||
("addon.shapes.spiral", &spiral, "innerRadius", [-0.1, 1.0], [0.0, 0.999]),
|
||||
] {
|
||||
let (min, max) = (good[0], good[1]);
|
||||
for x in bad {
|
||||
refused(&mut s, cmd, with(base, key, x), &format!("`{key}` must be between {min} and {max} (got {x})"));
|
||||
}
|
||||
for x in good {
|
||||
s.execute(cmd, &with(base, key, x)).unwrap_or_else(|e| panic!("{cmd} {key} {x}: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes are not negative.
|
||||
for (cmd, p, key) in [
|
||||
("addon.shapes.polygon", json!({"cx": 0.0, "cy": 0.0, "radius": -10.0}), "radius"),
|
||||
("addon.shapes.star", json!({"cx": 0.0, "cy": 0.0, "radius1": -10.0}), "radius1"),
|
||||
("addon.shapes.spiral", json!({"cx": 0.0, "cy": 0.0, "radius": -10.0}), "radius"),
|
||||
("addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": -10.0, "height": 10.0}), "width"),
|
||||
("addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": 10.0, "height": 10.0, "radius": -1.0}), "radius"),
|
||||
("addon.shapes.ellipse", json!({"x": 0.0, "y": 0.0, "width": 10.0, "height": -10.0}), "height"),
|
||||
("addon.shapes.box3d", json!({"x": 0.0, "y": 0.0, "width": -10.0, "height": 10.0}), "width"),
|
||||
] {
|
||||
refused(&mut s, cmd, p, &format!("`{key}` must be a number not below 0"));
|
||||
}
|
||||
|
||||
// Corner radii are four numbers that are not negative.
|
||||
for radii in [json!([1.0, 2.0, 3.0]), json!([1.0, 2.0, 3.0, -4.0]), json!([1.0, 2.0, 3.0, "4"]), json!(4.0)] {
|
||||
let p = json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "radii": radii});
|
||||
refused(&mut s, "addon.shapes.rectangle", p, "`radii` must be four numbers not below 0: [tl, tr, br, bl]");
|
||||
}
|
||||
|
||||
// An arc mode is one of the names the commands take.
|
||||
for mode in [json!("slices"), json!(""), json!(3)] {
|
||||
let p = json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "arcMode": mode});
|
||||
refused(&mut s, "addon.shapes.rectangle", p.clone(), "`arcMode` must be one of whole, pie, chord, arc");
|
||||
refused(&mut s, "addon.shapes.ellipse", p, "`arcMode` must be one of whole, pie, chord, arc");
|
||||
}
|
||||
for mode in ["whole", "pie", "slice", "chord", "arc", "open"] {
|
||||
s.execute("addon.shapes.ellipse", &json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "arcMode": mode})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_refuses_values_outside_their_ranges_before_changing_any_shape() {
|
||||
let mut s = new_session();
|
||||
let rect = id_of(&mut s, "addon.shapes.rectangle", json!({"x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0, "radius": 4.0}));
|
||||
let poly = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 50.0, "cy": 50.0, "radius": 40.0, "sides": 5}));
|
||||
let spiral = id_of(&mut s, "addon.shapes.spiral", json!({"cx": 60.0, "cy": 60.0, "radius": 45.0}));
|
||||
let ids = json!([rect.0, poly.0, spiral.0]);
|
||||
let cfg = |key: &str, v: serde_json::Value| json!({"ids": ids.clone(), key: v});
|
||||
let c = "addon.shapes.configure";
|
||||
for n in [json!(2.5), json!(2), json!(-1), json!(5000), json!(4_294_967_299u64)] {
|
||||
let x = n.as_f64().unwrap();
|
||||
refused(&mut s, c, cfg("sides", n), &format!("`sides` must be a whole number between 3 and 1000 (got {x})"));
|
||||
}
|
||||
for (key, bad, min, max) in [
|
||||
("spokeRatio", [0.0, 1.5], 0.01, 1.0),
|
||||
("rounded", [-10.5, 11.0], -10.0, 10.0),
|
||||
("randomized", [-11.0, 10.5], -10.0, 10.0),
|
||||
("turns", [0.0, 2000.0], 0.01, 1024.0),
|
||||
("divergence", [-1.0, 1000.5], 0.0, 1000.0),
|
||||
("innerRadius", [-0.5, 1.0], 0.0, 0.999),
|
||||
] {
|
||||
for x in bad {
|
||||
refused(&mut s, c, cfg(key, json!(x)), &format!("`{key}` must be between {min} and {max} (got {x})"));
|
||||
}
|
||||
}
|
||||
refused(&mut s, c, cfg("radius", json!(-1.0)), "`radius` must be a number not below 0");
|
||||
for radii in [json!([1.0, 2.0]), json!([1.0, 2.0, 3.0, -1.0]), json!("4")] {
|
||||
refused(&mut s, c, cfg("radii", radii), "`radii` must be four numbers not below 0: [tl, tr, br, bl]");
|
||||
}
|
||||
refused(&mut s, c, cfg("arcMode", json!("bogus")), "`arcMode` must be one of whole, pie, chord, arc");
|
||||
// A valid value next to a refused one changes no shape either.
|
||||
refused(&mut s, c, json!({"ids": ids.clone(), "sides": 8, "turns": 0.0}), "`turns` must be between 0.01 and 1024 (got 0)");
|
||||
|
||||
// In range, the values are taken as given; a radius larger than the rectangle takes is fitted to it.
|
||||
s.execute(c, &json!({"ids": [poly.0], "sides": 1000, "rounded": -10.0, "randomized": 10.0})).unwrap();
|
||||
assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 1000, rounded, randomized, .. } if rounded == -10.0 && randomized == 10.0));
|
||||
s.execute(c, &json!({"ids": [poly.0], "sides": 3})).unwrap();
|
||||
assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 3, .. }));
|
||||
s.execute(c, &json!({"ids": [spiral.0], "turns": 1024.0, "divergence": 0.0, "innerRadius": 0.999})).unwrap();
|
||||
assert!(
|
||||
matches!(shape_of(&s, spiral), CustomShape::Spiral { turns, divergence, inner_radius, .. } if turns == 1024.0 && divergence == 0.0 && inner_radius == 0.999)
|
||||
);
|
||||
s.execute(c, &json!({"ids": [rect.0], "radius": 1000.0})).unwrap();
|
||||
assert!(matches!(shape_of(&s, rect), CustomShape::Rectangle { radii, .. } if radii == [40.0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_records_no_undo_step_when_nothing_changes() {
|
||||
let mut s = new_session();
|
||||
let poly = id_of(&mut s, "addon.shapes.polygon", json!({"cx": 50.0, "cy": 50.0, "radius": 40.0, "sides": 5, "rounded": 0.5}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("addon.shapes.configure", &json!({"ids": [poly.0], "sides": 5, "rounded": 0.5})).unwrap();
|
||||
assert_eq!(r["updated"], 0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
|
||||
let r = s.execute("addon.shapes.configure", &json!({"ids": [poly.0], "sides": 6})).unwrap();
|
||||
assert_eq!(r["updated"], 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert!(matches!(shape_of(&s, poly), CustomShape::Polygon { sides: 6, .. }));
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ use super::paint::paint_from;
|
||||
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
|
||||
use super::{
|
||||
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets,
|
||||
object_param, ok, paint_targets, str_param, switch_or, targets,
|
||||
object_param, ok, paint_targets, str_param, switch_or, targets, u64_req,
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -604,9 +604,9 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
|
||||
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, read with [`index`].
|
||||
/// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`].
|
||||
pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
u64_req(p, key, cmd).map(index)
|
||||
}
|
||||
|
||||
fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Assets",
|
||||
[],
|
||||
None,
|
||||
"{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
|
||||
"{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64; warnings name a key in the rows or settings given that only another format takes, not one in the remembered settings). Leaves the remembered settings as they are (assets.settings.set changes them)",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
@@ -429,7 +429,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
q.insert("assets".into(), json!(all));
|
||||
}
|
||||
export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e))
|
||||
export_screens(s, &Value::Object(q), p).map_err(|e| of_cmd(C, e))
|
||||
}
|
||||
|
||||
fn settings_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -226,7 +226,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Break Link to Symbol",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask and its own bounding box (square after Reset Bounding Box); a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
|
||||
has_selection,
|
||||
symbol_break_link
|
||||
),
|
||||
|
||||
@@ -393,8 +393,10 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The plain art of instance `inst` in document space (fresh ids), with the instance's own
|
||||
/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice
|
||||
/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it
|
||||
/// when the art has a mask of its own.
|
||||
/// guides when it has them. The art's bounding boxes turn with the instance's own box, so after
|
||||
/// Reset Bounding Box they stand as the instance's box does. The instance's opacity mask goes on
|
||||
/// the art, or on a group around it when the art has a mask of its own; a group added around the
|
||||
/// art takes the instance's box angle.
|
||||
fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
|
||||
let sym = d.symbols.iter().find(|s| &s.name == symbol)?;
|
||||
@@ -402,12 +404,17 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
|
||||
if let Some((color, amount)) = brush::stain(inst) {
|
||||
brush::tint_node(&mut art, &color, amount);
|
||||
}
|
||||
let art = d.place_symbol_art(symbol, &art, *xf)?;
|
||||
let mut art = d.place_symbol_art(symbol, &art, *xf)?;
|
||||
let turn = vectorcraft_doc::orient::placed_turn(inst.bbox_angle, *xf);
|
||||
if turn != 0.0 {
|
||||
art.turn_boxes(turn);
|
||||
}
|
||||
let mut art = d.reid(&art);
|
||||
if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal {
|
||||
if !matches!(art.kind, NodeKind::Group { .. }) {
|
||||
let id = d.alloc_id();
|
||||
art = Node::group(id, vec![Arc::new(art)]);
|
||||
art.bbox_angle = inst.bbox_angle;
|
||||
}
|
||||
art.opacity *= inst.opacity;
|
||||
art.blend = inst.blend;
|
||||
@@ -416,6 +423,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
|
||||
if art.extras().mask.is_some() {
|
||||
let id = d.alloc_id();
|
||||
art = Node::group(id, vec![Arc::new(art)]);
|
||||
art.bbox_angle = inst.bbox_angle;
|
||||
}
|
||||
art.extras_mut().mask = Some(mask.clone());
|
||||
}
|
||||
|
||||
@@ -127,6 +127,17 @@ fn node_refs(n: &Node, f: Visit) {
|
||||
if let Some(cm) = Document::color_mask_of(m) {
|
||||
f(Ref::Name(Res::Image, &cm.key));
|
||||
}
|
||||
// A graph keeps paints and style names for its series and labels, which a redraw gives them.
|
||||
if let Some(g) = m.extras().graph.as_deref() {
|
||||
vectorcraft_doc::swatches::graph_paints(m).for_each(|p| paint_refs(p, f));
|
||||
let l = &g.label_styles;
|
||||
for s in [&l.values, &l.categories, &l.legend].into_iter().flatten().filter_map(|st| st.style_name.as_ref()) {
|
||||
f(Ref::Name(Res::CharStyle, s));
|
||||
}
|
||||
for s in [&l.values_para, &l.categories_para, &l.legend_para].into_iter().flatten().filter_map(|pa| pa.style_name.as_ref()) {
|
||||
f(Ref::Name(Res::ParaStyle, s));
|
||||
}
|
||||
}
|
||||
match &m.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &t.runs {
|
||||
@@ -671,6 +682,27 @@ impl Relink {
|
||||
self.name(Res::Image, &mut cm.key);
|
||||
self.name(Res::Image, &mut cm.from);
|
||||
}
|
||||
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
|
||||
for s in &mut g.series_paints {
|
||||
s.fill.iter_mut().chain(s.stroke.iter_mut()).for_each(|p| self.paint(p));
|
||||
}
|
||||
let l = &mut g.label_styles;
|
||||
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
|
||||
self.paint(&mut st.fill);
|
||||
self.paint(&mut st.stroke);
|
||||
if let Some(s) = &mut st.style_name {
|
||||
self.name(Res::CharStyle, s);
|
||||
}
|
||||
}
|
||||
for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() {
|
||||
if let Some(s) = &mut pa.style_name {
|
||||
self.name(Res::ParaStyle, s);
|
||||
}
|
||||
}
|
||||
for r in [&mut l.values_rows, &mut l.categories_rows, &mut l.legend_rows].into_iter().flatten() {
|
||||
r.paint.fill.iter_mut().chain(r.paint.stroke.iter_mut()).for_each(|p| self.paint(p));
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
|
||||
@@ -231,9 +231,9 @@ pub(super) fn within_input_bound(q: Point) -> bool {
|
||||
q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, read with [`index`].
|
||||
/// The whole number of 0 or more that `key` holds, read with [`u64_req`](crate::cmd::u64_req) and [`index`].
|
||||
pub(super) fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
crate::cmd::u64_req(p, key, cmd).map(index)
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
|
||||
|
||||
@@ -11,7 +11,9 @@ use super::{
|
||||
// ---------- Pencil / Paintbrush ----------
|
||||
|
||||
/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay
|
||||
/// corners) and simplify it to `tol`.
|
||||
/// corners) and simplify it to `tol`. A closed stroke is fitted as the open one is and closed by a
|
||||
/// straight segment from its end back to its start: fitting round the gap would curve that segment
|
||||
/// on from both ends, past them and back.
|
||||
fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE * 0.3);
|
||||
let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) };
|
||||
@@ -41,15 +43,26 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
(kept[i], corner)
|
||||
})
|
||||
.collect();
|
||||
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed);
|
||||
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, false);
|
||||
if sp.anchors.len() < 3 {
|
||||
return sp;
|
||||
// Two anchors closed would run back over the line itself.
|
||||
return SubPath::new(sp.anchors, closed && n > 2);
|
||||
}
|
||||
let fitted = po::simplify_with(
|
||||
&PathData::single(sp.clone()),
|
||||
&SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false, ..Default::default() },
|
||||
);
|
||||
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
|
||||
let mut anchors = fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp).anchors;
|
||||
if closed {
|
||||
// No handle on either side of the closing segment.
|
||||
if let Some(l) = anchors.last_mut() {
|
||||
l.h_out = l.p;
|
||||
}
|
||||
if let Some(f) = anchors.first_mut() {
|
||||
f.h_in = f.p;
|
||||
}
|
||||
}
|
||||
SubPath::new(anchors, closed)
|
||||
}
|
||||
|
||||
/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::clipboard::SwatchChoices;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param,
|
||||
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or,
|
||||
object_param, ok, point_param, str_param, switch_or,
|
||||
object_param, ok, point_param, str_param, switch_or, u64_req,
|
||||
};
|
||||
use crate::{Clipboard, EngineError, SourceLayer, inspect};
|
||||
|
||||
@@ -249,7 +249,10 @@ fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?;
|
||||
let i = match p.get("index") {
|
||||
None | Some(Value::Null) => return Err(bad("document.activate", "missing index")),
|
||||
Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.activate", "`index` must be a whole number"))?,
|
||||
};
|
||||
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
|
||||
}
|
||||
|
||||
@@ -262,7 +265,10 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if from >= n {
|
||||
return Err(bad("document.move", "no such document"));
|
||||
}
|
||||
let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.move", "missing `to`: a non-negative integer"))?;
|
||||
let to = match p.get("to") {
|
||||
None | Some(Value::Null) => return Err(bad("document.move", "missing `to`: a non-negative integer")),
|
||||
Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.move", "`to` must be a whole number"))?,
|
||||
};
|
||||
let to = to.min(n.saturating_sub(1));
|
||||
if !s.move_document(from, to) {
|
||||
return Err(bad("document.move", "no such document"));
|
||||
@@ -562,12 +568,11 @@ fn switch_units(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "units": next.label() }))
|
||||
}
|
||||
|
||||
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
|
||||
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when left out or null. A
|
||||
/// value that is not a whole number is "`artboard` must be a whole number"; one past the artboards
|
||||
/// is "index out of range".
|
||||
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
let index = match p.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?,
|
||||
};
|
||||
let index = super::index_opt(p, "artboard", c)?.unwrap_or(0);
|
||||
if index >= s.doc()?.doc.artboards.len() {
|
||||
return Err(bad(c, "index out of range"));
|
||||
}
|
||||
@@ -577,14 +582,15 @@ fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
// A document without artboards measures from its global zero point.
|
||||
let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
|
||||
let index =
|
||||
if d.artboards.is_empty() && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
|
||||
Ok(json!(s.doc()?.doc.ruler_origin_for(index)))
|
||||
}
|
||||
|
||||
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.setRulerOrigin";
|
||||
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? };
|
||||
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, C)? };
|
||||
change_ruler_origin(s, index, Some(origin), "Ruler Origin")
|
||||
}
|
||||
|
||||
@@ -1190,8 +1196,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Document ids scope node ids: two tabs may contain objects with the same node id.
|
||||
fn drop_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.dropSelection";
|
||||
let key = "sourceDocument";
|
||||
let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?;
|
||||
let uid = u64_req(p, "sourceDocument", CMD)?;
|
||||
let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates")));
|
||||
let grab = point("grab")?;
|
||||
let at = point("at")?;
|
||||
|
||||
@@ -865,8 +865,8 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
|
||||
/// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several
|
||||
/// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with
|
||||
/// fills, strokes or effects of its own or such members, other objects with effects, type with
|
||||
/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry
|
||||
/// effect ([`effects::has_item_geometry`])?
|
||||
/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry or
|
||||
/// raster effect ([`effects::has_item_geometry`], [`effects::has_instance_row_raster`])?
|
||||
pub(crate) fn expandable(n: &Node) -> bool {
|
||||
expandable_except(n, &[])
|
||||
}
|
||||
@@ -883,7 +883,9 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool {
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip))
|
||||
}
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
|
||||
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
|
||||
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||
effects::has_item_geometry(n) || effects::has_instance_row_raster(n) || !n.appearance.effects.is_empty()
|
||||
}
|
||||
_ => !n.appearance.effects.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub(crate) use save::save as save_document;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads,
|
||||
leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan,
|
||||
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused. Under automation read roots, an svg or svgz row is refused, before the folder is made or any file is written, while the document links a JavaScript file outside them, as document.export refuses it",
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused; a key only another format takes in a row or in settings is not used, and warnings name it as given (formats[0].quality; a key in settings only when no row of that format reads it; not a key of an exportPreset). Under automation read roots, an svg or svgz row is refused, before the folder is made or any file is written, while the document links a JavaScript file outside them, as document.export refuses it",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
|
||||
@@ -32,26 +32,25 @@ pub fn unused_option_notes(cmd: &str, f: &Format, p: &Value) -> Vec<String> {
|
||||
}
|
||||
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
|
||||
let written = super::save::written_as(cmd, f, p);
|
||||
let legacy = f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false);
|
||||
let reads = |k: &str| match (f.id, k) {
|
||||
// A legacy `.ai` file is not a PDF: it reads its own options only.
|
||||
("ai", _) if legacy => super::legacy_ai::reads(k),
|
||||
// The other `.ai` files don't read the legacy file's options, and `preserveEditing` is
|
||||
// on for them whatever is given.
|
||||
("ai", "preserveEditing") => false,
|
||||
("ai", _) if super::legacy_ai::LEGACY_ONLY.contains(&k) => false,
|
||||
// An export writes one file: only a save writes each artboard to a file of its own, and
|
||||
// a native file's `range` names those files.
|
||||
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
|
||||
_ => super::save::reads_option(f, k),
|
||||
};
|
||||
let legacy = super::save::legacy_file(cmd, f, p);
|
||||
given
|
||||
.iter()
|
||||
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !reads(k))
|
||||
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !export_reads(f, legacy, k))
|
||||
.map(|(k, _)| super::save::unused_option_note(k, &written))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Does the file an export writes in `f` read option `key`, `legacy` when it is a legacy `.ai`
|
||||
/// file? As a save's file does ([`super::save::file_reads`]), except that an export writes one
|
||||
/// file: only a save writes each artboard to a file of its own, and a native file's `range` names
|
||||
/// those files.
|
||||
pub(super) fn export_reads(f: &Format, legacy: bool, key: &str) -> bool {
|
||||
match (f.id, key) {
|
||||
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
|
||||
_ => super::save::file_reads(f, legacy, key),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
|
||||
check_linked_scripts("document.serialize", f.id, s.doc()?)?;
|
||||
|
||||
@@ -95,14 +95,18 @@ pub struct SavePlan {
|
||||
/// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file
|
||||
/// stamps; `None`: the time it is written.
|
||||
pub modified: Option<Option<i64>>,
|
||||
/// Warnings about the params themselves: each native option (`compress`, `version`,
|
||||
/// `preview`, `precision`) given beside the path, and each key of `options`, that the format
|
||||
/// doesn't read, naming the file the save writes.
|
||||
/// Warnings about the params themselves: each option a save format takes given beside the
|
||||
/// path, and each key of `options`, that the file the save writes doesn't read
|
||||
/// ([`file_reads`]), naming that file.
|
||||
pub notes: Vec<String>,
|
||||
/// The file the document was read from and what reading it left out, when the save needs
|
||||
/// `acknowledgeLoss` to write over it and was not given it: every file the save writes is
|
||||
/// checked against it before the first is written.
|
||||
pub lossy_source: Option<(String, Vec<String>)>,
|
||||
/// The `.ai` options the file doesn't read, with the values the document remembers from its
|
||||
/// last `.ai` save: not in `options`, they go back into the remembered options once the file
|
||||
/// is the document's own.
|
||||
pub kept: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl SavePlan {
|
||||
@@ -279,7 +283,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
}
|
||||
}
|
||||
// None given: the ones the document was last saved with in this format.
|
||||
let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
|
||||
let from_memory = given.is_empty() && format.id == st.format;
|
||||
let mut options = if from_memory { remembered_save_options(st) } else { given };
|
||||
// Remember the effective choice, including the encoder's default, so another document's
|
||||
// later save cannot change what this one writes. Invalid non-null values still reach validation.
|
||||
if matches!(format.id, "vectorcraft" | "template") {
|
||||
@@ -311,21 +316,54 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?;
|
||||
}
|
||||
let written = written_as(cmd, format, &Value::Object(options.clone()));
|
||||
let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string());
|
||||
let chosen = Value::Object(options.clone());
|
||||
let written = written_as(cmd, format, &chosen);
|
||||
let legacy = legacy_file(cmd, format, &chosen);
|
||||
// Beside the path, a key some save format takes; in `options`, any key.
|
||||
let top = p.as_object().into_iter().flatten().filter(|(k, v)| !v.is_null() && save_listed(k)).map(|(k, _)| k.to_string());
|
||||
let nested = p.get("options").and_then(Value::as_object).into_iter().flatten().filter(|(_, v)| !v.is_null()).map(|(k, _)| k);
|
||||
let notes = top
|
||||
.filter(|k| !reads_option(format, k))
|
||||
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
|
||||
.filter(|k| !file_reads(format, legacy, k))
|
||||
.chain(nested.filter(|k| !file_reads(format, legacy, k)).map(|k| format!("options.{k}")))
|
||||
.map(|k| unused_option_note(&k, &written))
|
||||
.collect();
|
||||
// When the document's own file is a `.ai`, the `.ai` options this file doesn't read and the
|
||||
// request doesn't give keep their remembered values ([`SavePlan::kept`]), so that Save Options
|
||||
// still shows them (it sends only the ones the file reads). They are neither written nor sent
|
||||
// again.
|
||||
let mut kept = Map::new();
|
||||
if format.id == "ai" && format.id == st.format {
|
||||
let last = remembered_save_options(st);
|
||||
for o in format.options.iter().filter(|o| !file_reads(format, legacy, o.name)) {
|
||||
let value = if from_memory {
|
||||
options.remove(o.name)
|
||||
} else if options.contains_key(o.name) {
|
||||
None
|
||||
} else {
|
||||
last.get(o.name).cloned()
|
||||
};
|
||||
if let Some(v) = value {
|
||||
kept.insert(o.name.to_string(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let lossy_source = super::lossy_source(st, p);
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source, kept })
|
||||
}
|
||||
|
||||
/// The native save options, which a save also takes beside the path.
|
||||
const NATIVE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "precision"];
|
||||
|
||||
/// Does some save format (or template format) take option `key`?
|
||||
fn save_listed(key: &str) -> bool {
|
||||
NATIVE_OPTIONS.contains(&key)
|
||||
|| super::FORMATS
|
||||
.iter()
|
||||
.filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
|
||||
.flat_map(|f| f.options)
|
||||
.any(|o| o.name == key)
|
||||
}
|
||||
|
||||
/// The first option `p` gives both beside the path and in `options` with different values, as
|
||||
/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on
|
||||
/// either side is the one its `svg: {…}` object gives, else the flat one.
|
||||
@@ -467,6 +505,26 @@ pub fn reads_option(f: &Format, key: &str) -> bool {
|
||||
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
|
||||
}
|
||||
|
||||
/// Does the file a save or an export writes in `f` read option `key`, `legacy` when it is a legacy
|
||||
/// `.ai` file ([`super::legacy_ai::wanted`])? As [`reads_option`], except for `.ai` and `.ait`
|
||||
/// files: a legacy file reads its own options only ([`super::legacy_ai::reads`]), and the others
|
||||
/// don't read the legacy file's options or `preserveEditing`, which is on for them whatever is
|
||||
/// given.
|
||||
pub fn file_reads(f: &Format, legacy: bool, key: &str) -> bool {
|
||||
match (f.id, key) {
|
||||
("ai", _) if legacy => super::legacy_ai::reads(key),
|
||||
("ai" | "ait", "preserveEditing") => false,
|
||||
("ai" | "ait", _) if super::legacy_ai::LEGACY_ONLY.contains(&key) => false,
|
||||
_ => reads_option(f, key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a save or an export of `cmd` in `f` with options `p` to write a legacy `.ai` file? An
|
||||
/// unknown version is not one (the save or export refuses it).
|
||||
pub(super) fn legacy_file(cmd: &str, f: &Format, p: &Value) -> bool {
|
||||
f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The note a save or an export gives for option `key`, as the request names it, which the file it
|
||||
/// writes (`written`, see [`written_as`]) doesn't read.
|
||||
pub(super) fn unused_option_note(key: &str, written: &Format) -> String {
|
||||
@@ -1037,6 +1095,7 @@ impl SaveJob {
|
||||
st.path = Some(path);
|
||||
st.format = self.plan.format.id;
|
||||
st.save_options = self.plan.options;
|
||||
st.save_options.extend(self.plan.kept);
|
||||
// A later save rounds only when it gives `precision` too.
|
||||
st.save_options.remove("precision");
|
||||
st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect();
|
||||
@@ -1239,7 +1298,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them; an svg or svgz save (a document whose own format is SVG or SVGZ included) is refused, before any file is written, while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), also by file.saveAs and file.saveCopy. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param};
|
||||
use super::encode::{add_note, single_artboard};
|
||||
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
|
||||
use super::export::{export_crop, export_reads, isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge,
|
||||
@@ -524,6 +524,77 @@ fn rows_of(s: &Session, p: &Value, preset: Option<&str>) -> Result<Vec<Row>> {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// A note for each option of a format row (`formats`) or of a format's `settings` in the request
|
||||
/// `asked` that some format takes but the file the row writes, or every row of that format writes,
|
||||
/// doesn't read (`{format: png, quality: 80}`), in the wording of a save's note and named as the
|
||||
/// request names it (`formats[0].quality`, `settings.png.quality`). The rows and settings the
|
||||
/// export writes with are those of `p` (`asked` with a preset or the remembered settings laid
|
||||
/// under it). A row's own keys (`scale`, `suffix`…), the keys the export reads for every format
|
||||
/// (`includeArtboardFill`, `richBlack`, `params`) and an artboard choice (the export picks the
|
||||
/// artboards) get none, and so does an exporter plug-in's format.
|
||||
pub(crate) fn unused_option_notes(asked: &Value, p: &Value) -> Vec<String> {
|
||||
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
|
||||
let own = |k: &str| {
|
||||
ROW_KEYS.contains(&k) || ARTBOARD_PARAMS.contains(&k) || matches!(k, "useArtboards" | "includeArtboardFill" | "richBlack" | "params")
|
||||
};
|
||||
// The keys of `given` that `f`, writing with `options` (the format's settings and the row's
|
||||
// own), doesn't read.
|
||||
let unread = |f: &Format, given: &Map<String, Value>, options: &Value| -> Vec<String> {
|
||||
if vectorcraft_plugins::export::by_format(f.id).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let legacy = super::save::legacy_file(C, f, options);
|
||||
given.iter().filter(|(k, v)| !v.is_null() && !own(k) && listed(k) && !export_reads(f, legacy, k)).map(|(k, _)| k.clone()).collect()
|
||||
};
|
||||
let note = |key: String, f: &Format, options: &Value| super::save::unused_option_note(&key, &super::save::written_as(C, f, options));
|
||||
let settings = p.get("settings").and_then(Value::as_object);
|
||||
let of_format = |id: &str| settings.and_then(|s| s.get(id)).and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
// A row's format and the options it writes with.
|
||||
let row_of = |row: &Value| -> Option<(&'static Format, Value)> {
|
||||
let (Some(row), Ok(f)) = (row.as_object(), writable(C, Some(str_param(row, "format").unwrap_or("png")), None)) else { return None };
|
||||
let mut options = of_format(f.id);
|
||||
options.extend(row.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
Some((f, Value::Object(options)))
|
||||
};
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
if let Some(Value::Array(rows)) = asked.get("formats") {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let (Some((f, options)), Some(given)) = (row_of(row), row.as_object()) else { continue };
|
||||
out.extend(unread(f, given, &options).into_iter().map(|k| note(format!("formats[{i}].{k}"), f, &options)));
|
||||
}
|
||||
}
|
||||
// The rows written, each with its own keys: a format's settings are read as each of its rows
|
||||
// writes them (a row may name another `.ai` version), else as they are.
|
||||
let written: Vec<(&Format, Value, Map<String, Value>)> = match p.get("formats") {
|
||||
Some(Value::Array(rows)) => {
|
||||
rows.iter().filter_map(|row| row_of(row).map(|(f, options)| (f, options, row.as_object().cloned().unwrap_or_default()))).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for (id, given) in asked.get("settings").and_then(Value::as_object).into_iter().flatten() {
|
||||
let (Some(given), Ok(f)) = (given.as_object(), writable(C, Some(id), None)) else { continue };
|
||||
let mut rows: Vec<(&Value, Option<&Map<String, Value>>)> =
|
||||
written.iter().filter(|(g, ..)| g.id == f.id).map(|(_, o, row)| (o, Some(row))).collect();
|
||||
let alone = Value::Object(of_format(f.id));
|
||||
if rows.is_empty() {
|
||||
rows.push((&alone, None));
|
||||
}
|
||||
let unread_by: Vec<Vec<String>> = rows.iter().map(|(o, _)| unread(f, given, o)).collect();
|
||||
// A setting a row reads and doesn't give itself is used. One no row uses is noted when a
|
||||
// row doesn't read it, naming the first such row's file.
|
||||
for (k, v) in given {
|
||||
// A row's own key, null too, replaces the setting.
|
||||
let overridden = |row: Option<&Map<String, Value>>| row.is_some_and(|r| r.contains_key(k));
|
||||
let used = rows.iter().zip(&unread_by).any(|((_, row), u)| !u.contains(k) && !overridden(*row));
|
||||
let unread_row = rows.iter().zip(&unread_by).find(|(_, u)| u.contains(k));
|
||||
if let (false, false, Some(((options, _), _))) = (v.is_null(), used, unread_row) {
|
||||
out.push(note(format!("settings.{id}.{k}"), f, options));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Check the export settings in `p` (formats, preset, settings…) as an export would read them.
|
||||
pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
|
||||
rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ())
|
||||
@@ -646,9 +717,10 @@ impl Output {
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A folder the client gave, as against the one its `exportPreset` keeps.
|
||||
let given = p.get("folder").is_some();
|
||||
let asked = p;
|
||||
let p = with_preset(s, p)?;
|
||||
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
|
||||
let mut r = export(s, &p).map_err(|e| match hidden {
|
||||
let mut r = export(s, &p, asked).map_err(|e| match hidden {
|
||||
Some(folder) => super::access::naming_in_error(e, folder),
|
||||
None => e,
|
||||
})?;
|
||||
@@ -1022,9 +1094,12 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map<Stri
|
||||
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
|
||||
/// so do different files that would share a name, with or without the `name` pattern. The reply's
|
||||
/// `warnings` lists every warning the encoders give for the files, each once, what the files lose
|
||||
/// first ([`super::note_texts`]).
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// first ([`super::note_texts`]), and a note for each option of the request `asked` (`p` before a
|
||||
/// preset or the remembered settings were laid under it) that the files the rows write don't read
|
||||
/// ([`unused_option_notes`]).
|
||||
pub(crate) fn export(s: &mut Session, p: &Value, asked: &Value) -> Result<Value> {
|
||||
check_passwords(p)?;
|
||||
let unused = unused_option_notes(asked, p);
|
||||
let o = ScreenParams::of(p)?;
|
||||
let rows = rows_of(s, p, o.preset.as_deref())?;
|
||||
o.one_source()?;
|
||||
@@ -1054,7 +1129,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Include Artboard Fill off: the encoders leave the background colors out.
|
||||
let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) };
|
||||
let rows = rows.into_iter().map(|r| Row { options: merge(with_black_output(s, &r.options), fill.clone()), ..r }).collect();
|
||||
let export = ScreenExport { cmd: C, o, rows, naming, items, hidden: None };
|
||||
let export = ScreenExport { cmd: C, o, rows, naming, items, unused, hidden: None };
|
||||
match &mut s.screens_hand_off {
|
||||
Some(slot) => {
|
||||
*slot = Some(export);
|
||||
@@ -1075,6 +1150,8 @@ pub struct ScreenExport {
|
||||
rows: Vec<Row>,
|
||||
naming: Naming,
|
||||
items: Vec<Item>,
|
||||
/// The notes on options the files don't read ([`unused_option_notes`]).
|
||||
unused: Vec<String>,
|
||||
/// The export folder and the name the request that prepared the export may see it by, when the
|
||||
/// folder came from the `exportPreset` and is outside that request's read roots: the reply and
|
||||
/// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does.
|
||||
@@ -1139,14 +1216,17 @@ impl ScreenExport {
|
||||
|
||||
/// [`ScreenExport::write`].
|
||||
fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let ScreenExport { o, rows, naming, items, .. } = export;
|
||||
let ScreenExport { o, rows, naming, items, unused, .. } = export;
|
||||
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let mut out = Output::default();
|
||||
// The encoders' notes for every file, each once.
|
||||
// The notes on the options, then the encoders' notes for every file, each once.
|
||||
let mut notes: Vec<Note> = Vec::new();
|
||||
for text in unused {
|
||||
add_note(&mut notes, Note::setting(text));
|
||||
}
|
||||
// Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are
|
||||
// written on a page of their own bounds, which it doesn't grow.
|
||||
let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none();
|
||||
|
||||
@@ -748,8 +748,8 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
|
||||
}
|
||||
|
||||
/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different
|
||||
/// values is refused before anything is written; `compress`, `version`, `preview` or `precision` beside the path of a format
|
||||
/// that doesn't read it is named in the warnings.
|
||||
/// values is refused before anything is written; a native option beside the path of a format that doesn't read it is named
|
||||
/// in the warnings.
|
||||
#[test]
|
||||
fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {
|
||||
let d = tmp_dir("save-options");
|
||||
|
||||
@@ -280,3 +280,102 @@ fn note_texts_give_the_losses_first_each_kind_in_order() {
|
||||
assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]);
|
||||
assert!(note_texts(&[]).is_empty());
|
||||
}
|
||||
|
||||
/// A save notes the `.ai` options its file doesn't read as an export does: a PDF-compatible file
|
||||
/// doesn't read the legacy file's options or `preserveEditing`, and a legacy file reads its own
|
||||
/// options only. The notes cover the options beside the path and in `options`.
|
||||
#[test]
|
||||
fn a_save_notes_the_ai_options_its_file_does_not_read() {
|
||||
let (mut s, notes) = (with_art(), unused);
|
||||
let r = s
|
||||
.execute("file.saveCopy", &json!({"format": "ai", "outlineText": true, "preserveEditing": false, "options": {"hiddenObjects": "omit"}}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
notes(&r),
|
||||
[
|
||||
"`outlineText` was not used: PDF-compatible .ai files don't read it",
|
||||
"`preserveEditing` was not used: PDF-compatible .ai files don't read it",
|
||||
"`options.hiddenObjects` was not used: PDF-compatible .ai files don't read it"
|
||||
],
|
||||
"{r}"
|
||||
);
|
||||
let p = json!({"format": "ai", "aiVersion": "7", "compress": true, "marks": {"trim": true}, "options": {"pdfCompatible": false}});
|
||||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||||
assert_eq!(
|
||||
notes(&r),
|
||||
[
|
||||
"`compress` was not used: legacy .ai (version 7) files don't read it",
|
||||
"`marks` was not used: legacy .ai (version 7) files don't read it",
|
||||
"`options.pdfCompatible` was not used: legacy .ai (version 7) files don't read it"
|
||||
],
|
||||
"{r}"
|
||||
);
|
||||
// The file's own options get none.
|
||||
for p in [
|
||||
json!({"format": "ai", "aiVersion": "7", "outlineText": true, "options": {"hiddenObjects": "omit", "range": "1"}}),
|
||||
json!({"format": "ai", "compress": true, "marks": {"trim": true}, "options": {"editingData": false}}),
|
||||
json!({"format": "pdf", "marks": {"trim": true}, "preserveEditing": false}),
|
||||
] {
|
||||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||||
assert!(notes(&r).is_empty(), "{p}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A save notes an option beside the path that only another save format takes, as it notes one in
|
||||
/// `options`.
|
||||
#[test]
|
||||
fn a_save_notes_an_option_beside_the_path_that_only_another_save_format_takes() {
|
||||
let (mut s, notes) = (with_art(), unused);
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "vectorcraft", "marks": {"trim": true}, "svg": {"minify": true}})).unwrap();
|
||||
assert_eq!(notes(&r), ["`marks` was not used: VectorCraft files don't read it", "`svg` was not used: VectorCraft files don't read it"], "{r}");
|
||||
}
|
||||
|
||||
/// The `.ai` options a save's file doesn't read keep the values the document's last save in that
|
||||
/// format gave them, so a save from Save Options, which sends only the options the file reads,
|
||||
/// leaves the other version's choices as they were.
|
||||
#[test]
|
||||
fn a_save_keeps_the_ai_options_its_file_does_not_read_from_the_last_save() {
|
||||
let mut s = with_art();
|
||||
let dir = tmp_dir("ai-kept");
|
||||
let path = dir.join("kept.ai").to_string_lossy().to_string();
|
||||
let value = |s: &mut Session, key: &str| s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap()["options"][key]["value"].clone();
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": true}})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "pdf", "editingData": false}})).unwrap();
|
||||
assert_eq!(value(&mut s, "outlineText"), json!(true));
|
||||
assert_eq!(value(&mut s, "editingData"), json!(false));
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "hiddenObjects": "omit"}})).unwrap();
|
||||
assert_eq!(value(&mut s, "editingData"), json!(false));
|
||||
// An option the request gives is the one saved.
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": false}})).unwrap();
|
||||
assert_eq!(value(&mut s, "outlineText"), json!(false));
|
||||
// The kept options are neither written nor sent again: a plain save's options (which a retry
|
||||
// in another folder sends to Save As) hold only what the legacy file reads.
|
||||
for p in [json!({}), json!({"options": {"aiVersion": "7"}})] {
|
||||
let plan = save_plan(&s, SaveMode::Save, &p).unwrap();
|
||||
assert!(!plan.options.contains_key("editingData") && !plan.options.contains_key("compress"), "{:?}", plan.options);
|
||||
assert_eq!(plan.kept.get("editingData"), Some(&json!(false)));
|
||||
assert!(plan.notes.is_empty(), "{:?}", plan.notes);
|
||||
let retry = json!({"path": path, "format": "ai", "options": plan.options});
|
||||
let r = s.execute("file.saveAs", &retry).unwrap();
|
||||
assert!(!r["warnings"].to_string().contains("was not used"), "{r}");
|
||||
assert_eq!(value(&mut s, "editingData"), json!(false));
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// The first `.ai` save of a document whose own file is another format remembers only the options
|
||||
/// it was given: nothing from that format's options is kept.
|
||||
#[test]
|
||||
fn a_first_ai_save_keeps_only_the_options_it_is_given() {
|
||||
let mut s = with_art();
|
||||
let dir = tmp_dir("ai-first");
|
||||
let native = dir.join("first.vectorcraft").to_string_lossy().to_string();
|
||||
let path = dir.join("first.ai").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", &json!({"path": native, "options": {"compress": false}})).unwrap();
|
||||
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
|
||||
assert!(plan.kept.is_empty(), "{:?}", plan.kept);
|
||||
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
|
||||
let o = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
|
||||
assert_eq!(o["options"]["compress"]["value"], json!(true), "{o}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -304,11 +304,22 @@ fn options_not_applied_yet_and_document_features_warn() {
|
||||
let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap();
|
||||
assert_eq!(warnings(&export(&mut s, json!({}))).len(), 0);
|
||||
// A knockout group is approximated: its warning comes back from every PDF path.
|
||||
// A knockout group is written as a PDF knockout group: nothing to report.
|
||||
s.execute("select.set", &json!({"ids": [frame]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
|
||||
let knockout = |w: Vec<String>| w.iter().any(|w| w.contains("knockout groups"));
|
||||
assert!(!knockout(warnings(&export(&mut s, json!({})))));
|
||||
// One whose soft masks would draw a brushed stroke is approximated: its warning comes back
|
||||
// from every PDF path.
|
||||
let rect =
|
||||
|s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40));
|
||||
s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
|
||||
s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
|
||||
assert!(knockout(warnings(&export(&mut s, json!({})))));
|
||||
assert!(knockout(warnings(&s.execute("document.export", &as_export(&json!({}))).unwrap())));
|
||||
// Other formats have none.
|
||||
@@ -432,6 +443,17 @@ fn pdf_settings_summarise_changes_and_warnings() {
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [g]})).unwrap();
|
||||
assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([]));
|
||||
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
|
||||
// A knockout group is written as a PDF knockout group: nothing to report.
|
||||
assert_eq!(s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap()["warnings"], json!([]));
|
||||
// One whose soft masks would draw a brushed stroke is approximated.
|
||||
let rect =
|
||||
|s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 45, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
let (lower, upper) = (rect(&mut s, 20), rect(&mut s, 40));
|
||||
s.execute("brush.apply", &json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
|
||||
s.execute("object.setProps", &json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [lower, upper]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("transparency.set", &json!({"knockout": "on"})).unwrap();
|
||||
assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["warnings"], json!([]));
|
||||
let full = s.execute("document.pdfSettings", &json!({"includeDocument": true})).unwrap();
|
||||
assert!(warnings(&full).iter().any(|w| w.contains("knockout groups")), "{}", full["warnings"]);
|
||||
|
||||
@@ -1163,3 +1163,93 @@ fn single_file_pdfs_hold_every_chosen_artboard() {
|
||||
assert_eq!(names(&r), ["Artboard-1.pdf", "Artboard-2.pdf"]);
|
||||
assert!(s.execute("document.exportForScreens", &json!({"pdfs": "both", "formats": [{"format": "pdf"}]})).is_err());
|
||||
}
|
||||
|
||||
/// The warnings of reply `r` that name an option the file doesn't read.
|
||||
fn unused(r: &Value) -> Vec<String> {
|
||||
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// An option of a format row, or of a format's settings, that only another format reads is
|
||||
/// accepted, and the reply's warnings name it as the request does, in the words of a save's note.
|
||||
#[test]
|
||||
fn a_row_option_only_another_format_reads_gets_a_note() {
|
||||
let mut s = session(1);
|
||||
let p = json!({
|
||||
"formats": [{"format": "png", "quality": 80}, {"format": "jpg", "quality": 80}, {"format": "ai", "aiVersion": "7", "compress": true}],
|
||||
"settings": {"png": {"quality": 50, "interlaced": true}, "svg": {"interlaced": true}},
|
||||
});
|
||||
let r = export(&mut s, p);
|
||||
assert_eq!(
|
||||
unused(&r),
|
||||
[
|
||||
"`formats[0].quality` was not used: PNG files don't read it",
|
||||
"`formats[2].compress` was not used: legacy .ai (version 7) files don't read it",
|
||||
"`settings.png.quality` was not used: PNG files don't read it",
|
||||
"`settings.svg.interlaced` was not used: SVG files don't read it"
|
||||
],
|
||||
"{r}"
|
||||
);
|
||||
// A row's own keys, the keys every row takes and the artboard choices get none.
|
||||
let p = json!({"formats": [
|
||||
{"format": "png", "scale": "2x", "suffix": "@2x", "folder": "x", "includeArtboardFill": false, "artboard": 0, "useArtboards": true},
|
||||
{"format": "pdf", "marks": {"trim": true}, "preset": "High Quality Print"},
|
||||
{"format": "vectorcraft", "compress": true, "range": "1"},
|
||||
]});
|
||||
let r = export(&mut s, p);
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
// A format's settings are read as each row of that format writes them: a row can name another
|
||||
// `.ai` version than the settings do.
|
||||
let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "7"}], "settings": {"ai": {"marks": {"trim": true}}}}));
|
||||
assert_eq!(unused(&r), ["`settings.ai.marks` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "pdf"}], "settings": {"ai": {"aiVersion": "7", "compress": true}}}));
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
// A setting another row of the format reads gets none; one no row reads names the first row's
|
||||
// file.
|
||||
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai"}]);
|
||||
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"quality": 80}}}));
|
||||
assert_eq!(unused(&r), ["`settings.ai.quality` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
// A row that gives the key itself doesn't use the setting.
|
||||
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": true}]);
|
||||
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
|
||||
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": null}]);
|
||||
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
|
||||
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
// An export writes one file per row and item: a native row doesn't read `separateArtboards`.
|
||||
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft", "separateArtboards": true}]}));
|
||||
assert_eq!(unused(&r), ["`formats[0].separateArtboards` was not used: VectorCraft files don't read it"], "{r}");
|
||||
}
|
||||
|
||||
/// The Asset Export panel's export notes the options of its request, not those of the settings
|
||||
/// the document remembers.
|
||||
#[test]
|
||||
fn an_asset_export_notes_the_options_it_is_given() {
|
||||
let mut s = session(1);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("assets.add", &json!({"multiple": false})).unwrap();
|
||||
s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
|
||||
let r = s.execute("assets.export", &json!({})).unwrap();
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
let r = s.execute("assets.export", &json!({"formats": [{"format": "svg", "quality": 80}]})).unwrap();
|
||||
assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
|
||||
// Settings given are read as the remembered rows write them.
|
||||
s.execute("assets.settings.set", &json!({"formats": [{"format": "ai", "aiVersion": "7"}]})).unwrap();
|
||||
let r = s.execute("assets.export", &json!({"settings": {"ai": {"editingData": false}}})).unwrap();
|
||||
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
|
||||
s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
|
||||
// So does the export a client prepares.
|
||||
let p = json!({"formats": [{"format": "svg", "quality": 80}]});
|
||||
let r = s.prepare_screen_export("assets.export", &p).unwrap().write(&mut |_: &str, _: &[u8]| Ok(())).unwrap();
|
||||
assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
|
||||
}
|
||||
|
||||
/// A preset's options add no notes: Export for Screens notes the options of its request.
|
||||
#[test]
|
||||
fn an_export_preset_adds_no_notes() {
|
||||
let mut s = session(1);
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Mixed", "settings": {"formats": [{"format": "png", "quality": 80}]}})).unwrap();
|
||||
let r = export(&mut s, json!({"exportPreset": "Mixed"}));
|
||||
assert!(unused(&r).is_empty(), "{r}");
|
||||
}
|
||||
|
||||
@@ -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 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, 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, with the gradient fitted to each label",
|
||||
has_doc,
|
||||
set_gradient_geom
|
||||
),
|
||||
|
||||
@@ -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_opacity};
|
||||
use super::series::{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,
|
||||
};
|
||||
@@ -1006,7 +1006,7 @@ impl<'a> Plot<'a> {
|
||||
}
|
||||
}
|
||||
let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1));
|
||||
let grp = self.b.series(&name, s, with_series_opacity(g, s, items));
|
||||
let grp = self.b.series(&name, s, with_series_opacity(g, s, with_series_gradients(g, s, items)));
|
||||
self.b.out.push(grp);
|
||||
}
|
||||
self.b.out.append(&mut self.on_top);
|
||||
|
||||
@@ -2,7 +2,7 @@ use vectorcraft_color::BlendMode;
|
||||
|
||||
use super::axis::radar_axis;
|
||||
use super::labels::{Role, filled_lines_on, label_para, label_style};
|
||||
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
|
||||
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad};
|
||||
|
||||
// ---------- generation ----------
|
||||
|
||||
@@ -229,7 +229,8 @@ pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, b
|
||||
continue;
|
||||
}
|
||||
let (paint, width) = shown_paint(node, *fill);
|
||||
captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width)));
|
||||
let width = (!*fill).then_some(width);
|
||||
captures.push((r.graph, r.index, *fill, paint, width, paint_box(node, width)));
|
||||
}
|
||||
store_series_paints(doc, captures)
|
||||
}
|
||||
@@ -281,11 +282,129 @@ fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool
|
||||
changed
|
||||
}
|
||||
|
||||
/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a
|
||||
/// The box a series keeps a gradient relative to: (0, 0)–(1, 1), as graphic styles keep theirs.
|
||||
const UNIT_BOX: Rect = vectorcraft_doc::GraphicStyle::UNIT_BOX;
|
||||
|
||||
/// The gradient `slot` keeps for its fill (`fill`) or stroke as the graph draws it on box `on`: one kept relative to the
|
||||
/// unit box placed on `on`, also when `on` has no width or no height, and one kept in document coordinates as it is kept.
|
||||
/// None when the slot keeps no gradient, or keeps one relative to the unit box and there is no box.
|
||||
fn kept_on(slot: &vectorcraft_doc::SeriesPaint, fill: bool, on: Option<Rect>) -> Option<Paint> {
|
||||
let (kept, unit) = if fill { (&slot.fill, slot.fill_unit_box) } else { (&slot.stroke, slot.stroke_unit_box) };
|
||||
let Some(Paint::Gradient(g)) = kept else { return None };
|
||||
if !unit {
|
||||
return Some(Paint::Gradient(g.clone()));
|
||||
}
|
||||
let on = on?;
|
||||
let mut g = g.clone();
|
||||
g.rebase(UNIT_BOX, on);
|
||||
Some(Paint::Gradient(g))
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` are the same paint, a gradient's vector, focal point and freeform points each within 1e-6 pt (the
|
||||
/// rounding a move or a placement on another box leaves).
|
||||
fn same_paint_placed(a: &Paint, b: &Paint) -> bool {
|
||||
let (Paint::Gradient(a), Paint::Gradient(b)) = (a, b) else { return a == b };
|
||||
let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= 1e-6;
|
||||
let mut a = (**a).clone();
|
||||
match (&mut a.geom, &b.geom) {
|
||||
(Some(ag), Some(bg)) => {
|
||||
let focal = match (ag.focal, bg.focal) {
|
||||
(Some(p), Some(q)) => near(p, q),
|
||||
(p, q) => p.is_none() && q.is_none(),
|
||||
};
|
||||
if !(near(ag.start, bg.start) && near(ag.end, bg.end) && focal) {
|
||||
return false;
|
||||
}
|
||||
(ag.start, ag.end, ag.focal) = (bg.start, bg.end, bg.focal);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => return false,
|
||||
}
|
||||
match (&mut a.freeform, &b.freeform) {
|
||||
(Some(af), Some(bf)) => {
|
||||
if af.points.len() != bf.points.len() || !af.points.iter().zip(&bf.points).all(|(p, q)| near(p.at, q.at)) {
|
||||
return false;
|
||||
}
|
||||
for (p, q) in af.points.iter_mut().zip(&bf.points) {
|
||||
p.at = q.at;
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => return false,
|
||||
}
|
||||
a == **b
|
||||
}
|
||||
|
||||
/// The box a gradient on `node`'s fill (`width` None) or on its stroke of `width` is placed on: the geometric bounds, grown
|
||||
/// by half the weight for a stroke. The box can have no width or no height.
|
||||
fn paint_box(node: &Node, width: Option<f64>) -> Option<Rect> {
|
||||
let b = node.geometric_bounds()?;
|
||||
Some(width.map_or(b, |w| vectorcraft_doc::appearance::stroke_paint_bounds(b, w)))
|
||||
}
|
||||
|
||||
/// `paint` as a series keeps it: a gradient placed on `from` (the edited mark's [`paint_box`]) is moved to the same place
|
||||
/// relative to [`UNIT_BOX`], so each mark takes it on its own box. A box with no width or no height, which a gradient's
|
||||
/// place cannot be kept relative to, leaves the paint in document coordinates. Returns the paint and whether it was moved.
|
||||
fn unit_box_paint(paint: Paint, from: Option<Rect>) -> (Paint, bool) {
|
||||
match (paint, from.filter(|b| b.width() > 1e-9 && b.height() > 1e-9)) {
|
||||
(Paint::Gradient(mut g), Some(from)) => {
|
||||
g.rebase(from, UNIT_BOX);
|
||||
(Paint::Gradient(g), true)
|
||||
}
|
||||
(paint, _) => (paint, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths directly inside series `i`'s group (`items`) with the series' kept gradients, which are kept relative to the
|
||||
/// unit box, placed on each path's own box: a fill on its geometric bounds, a stroke on its stroke's box. Paints kept in
|
||||
/// document coordinates (from files saved before the unit box) are left as they are.
|
||||
pub(super) fn with_series_gradients(g: &GraphSpec, i: usize, mut items: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
|
||||
let Some(slot) = g.series_paints.get(i) else { return items };
|
||||
fn kept(p: &Option<Paint>, unit: bool) -> Option<&Paint> {
|
||||
p.as_ref().filter(|p| unit && matches!(p, Paint::Gradient(_)))
|
||||
}
|
||||
let (fill, stroke) = (kept(&slot.fill, slot.fill_unit_box), kept(&slot.stroke, slot.stroke_unit_box));
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
return items;
|
||||
}
|
||||
// A Filled Lines band takes the kept stroke as its fill when no fill is kept.
|
||||
let band_fill = if slot.fill.is_none() { stroke } else { None };
|
||||
let fill_from_store = |p: &Paint| fill == Some(p) || band_fill == Some(p);
|
||||
let stroke_from_store = |p: &Paint| stroke == Some(p);
|
||||
for item in &mut items {
|
||||
if !matches!(item.kind, NodeKind::Path { .. }) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = item.geometric_bounds() else { continue };
|
||||
let ap = &item.appearance;
|
||||
if !ap.fill().is_some_and(|f| fill_from_store(&f.paint)) && !ap.stroke().is_some_and(|st| stroke_from_store(&st.paint)) {
|
||||
continue;
|
||||
}
|
||||
let ap = &mut Arc::make_mut(item).appearance;
|
||||
if let Some(f) = ap.fill_mut().filter(|f| fill_from_store(&f.paint))
|
||||
&& let Paint::Gradient(gp) = &mut f.paint
|
||||
{
|
||||
gp.rebase(UNIT_BOX, b);
|
||||
}
|
||||
if let Some(st) = ap.stroke_mut().filter(|st| stroke_from_store(&st.paint)) {
|
||||
let to = st.paint_bounds(b);
|
||||
if let Paint::Gradient(gp) = &mut st.paint {
|
||||
gp.rebase(UNIT_BOX, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// A paint taken from a series mark: graph, series index, fill or stroke, paint, stroke weight and the mark's
|
||||
/// [`paint_box`].
|
||||
type Capture = (NodeId, usize, bool, Paint, Option<f64>, Option<Rect>);
|
||||
|
||||
/// Store each capture in its series; a gradient is kept relative to the unit box ([`unit_box_paint`]). Returns whether a
|
||||
/// series' captured fill or stroke differs from what the graph stored before the first capture.
|
||||
fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option<f64>)>) -> bool {
|
||||
fn store_series_paints(doc: &mut Document, captures: Vec<Capture>) -> bool {
|
||||
let mut before = SeriesPaintsBefore::new();
|
||||
for (graph, index, fill, paint, width) in captures {
|
||||
for (graph, index, fill, paint, width, from) in captures {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
|
||||
before.entry(graph).or_insert_with(|| spec.series_paints.clone());
|
||||
// `index` is below the graph's series count (`series_members`).
|
||||
@@ -293,10 +412,17 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P
|
||||
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
|
||||
}
|
||||
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
|
||||
// A mark that shows the kept gradient as it was drawn leaves the kept gradient as it is.
|
||||
let drawn = kept_on(slot, fill, from).is_some_and(|kept| same_paint_placed(&kept, &paint));
|
||||
let (paint, unit) = unit_box_paint(paint, from);
|
||||
if fill {
|
||||
slot.fill = Some(paint);
|
||||
if !drawn {
|
||||
(slot.fill, slot.fill_unit_box) = (Some(paint), unit);
|
||||
}
|
||||
} else {
|
||||
slot.stroke = Some(paint);
|
||||
if !drawn {
|
||||
(slot.stroke, slot.stroke_unit_box) = (Some(paint), unit);
|
||||
}
|
||||
slot.stroke_width = width;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2754,10 +2754,39 @@ fn a_gradient_edit_on_a_series_mark_is_kept() {
|
||||
series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was);
|
||||
}
|
||||
|
||||
/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and
|
||||
/// every mark of the series has that fill, placement included, after the graph is drawn again.
|
||||
/// Where gradient `paint` sits on box `b`: its vector's start and end, then each freeform point, as fractions of the box.
|
||||
fn gradient_place(paint: &Paint, b: vectorcraft_geom::Rect) -> Vec<f64> {
|
||||
let Paint::Gradient(g) = paint else { panic!("not a gradient: {paint:?}") };
|
||||
let points = g.geom.iter().flat_map(|geom| [geom.start, geom.end]).chain(g.freeform.iter().flat_map(|f| f.points.iter().map(|p| p.at)));
|
||||
points.flat_map(|p| [(p.x - b.x0) / b.width(), (p.y - b.y0) / b.height()]).collect()
|
||||
}
|
||||
|
||||
/// The box a gradient on `mark`'s fill (`stroke` false) or stroke is placed on.
|
||||
fn paint_box_of(mark: &vectorcraft_doc::Node, stroke: bool) -> vectorcraft_geom::Rect {
|
||||
let b = mark.geometric_bounds().unwrap();
|
||||
if stroke { vectorcraft_doc::appearance::stroke_paint_bounds(b, mark.appearance.stroke_width()) } else { b }
|
||||
}
|
||||
|
||||
/// The fill (`stroke` false) or stroke of every path of series 0, the legend swatch included, has a gradient at `place` on
|
||||
/// its own box, and the gradients are not all at one place in the document. Returns how many paths were checked.
|
||||
fn series_0_gradients_at(s: &Session, id: NodeId, stroke: bool, place: &[f64]) -> usize {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let paths: Vec<_> = series(&n, 0).children().unwrap().iter().filter(|m| matches!(m.kind, NodeKind::Path { .. })).cloned().collect();
|
||||
let paints: Vec<Paint> = paths.iter().map(|m| if stroke { m.appearance.stroke_paint() } else { m.appearance.fill_paint() }).collect();
|
||||
for (m, paint) in paths.iter().zip(&paints) {
|
||||
let at = gradient_place(paint, paint_box_of(m, stroke));
|
||||
assert_eq!(at.len(), place.len(), "{at:?}");
|
||||
assert!(at.iter().zip(place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}");
|
||||
}
|
||||
assert!(paints.windows(2).any(|w| w[0] != w[1]), "every mark has the gradient at one place: {paints:?}");
|
||||
paths.len()
|
||||
}
|
||||
|
||||
/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored relative to the mark's box, and every
|
||||
/// mark of the series has it at the same place on its own box after the graph is drawn again, also after a data change
|
||||
/// moves and resizes the marks and after the graph is moved.
|
||||
#[test]
|
||||
fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() {
|
||||
fn a_gradient_placed_on_a_series_mark_takes_the_same_place_on_every_mark() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
// `paint.setFill` stores the gradient before it is placed.
|
||||
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
|
||||
@@ -2766,26 +2795,258 @@ fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() {
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
|
||||
let placed = fill_of(&s, marks[0]);
|
||||
assert_ne!(placed, fill, "the command places the gradient");
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone()));
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0));
|
||||
let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
|
||||
let place = gradient_place(&placed, paint_box_of(&mark, false));
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert!(kept.fill_unit_box);
|
||||
assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place);
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
|
||||
assert!(series_0_gradients_at(&s, id, false, &place) >= 4);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 50, "dy": 30})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [5, 4], [1, 1]]})).unwrap();
|
||||
series_0_gradients_at(&s, id, false, &place);
|
||||
}
|
||||
|
||||
/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph.
|
||||
/// A gradient placed on a series mark's stroke is kept relative to the mark's stroke box, and every mark of the series has
|
||||
/// it at the same place on its own stroke box after the graph is drawn again.
|
||||
#[test]
|
||||
fn a_gradient_placed_on_a_series_marks_stroke_takes_the_same_place_on_every_mark() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap();
|
||||
s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap();
|
||||
let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone();
|
||||
let place = gradient_place(&mark.appearance.stroke_paint(), paint_box_of(&mark, true));
|
||||
assert!(spec(&s, id).series_paints[0].stroke_unit_box);
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
|
||||
series_0_gradients_at(&s, id, true, &place);
|
||||
}
|
||||
|
||||
/// A placed gradient picked up with the Eyedropper from art outside the graph is kept relative to the series mark it is
|
||||
/// dropped on, and every mark of the series has it at the same place on its own box.
|
||||
#[test]
|
||||
fn a_placed_gradient_dropped_on_a_series_mark_takes_the_same_place_on_every_mark() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [marks[2].0]})).unwrap();
|
||||
let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone();
|
||||
let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false));
|
||||
assert!(spec(&s, id).series_paints[0].fill_unit_box);
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
|
||||
series_0_gradients_at(&s, id, false, &place);
|
||||
}
|
||||
|
||||
/// A graphic style with a placed gradient applied to a series mark is kept relative to that mark's box, and every mark of
|
||||
/// the series has the gradient at the same place on its own box.
|
||||
#[test]
|
||||
fn a_graphic_style_gradient_applied_to_a_series_mark_takes_the_same_place_on_every_mark() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap();
|
||||
s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Placed"})).unwrap();
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Placed", "ids": [marks[2].0]})).unwrap();
|
||||
let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone();
|
||||
let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false));
|
||||
assert!(spec(&s, id).series_paints[0].fill_unit_box);
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
|
||||
series_0_gradients_at(&s, id, false, &place);
|
||||
}
|
||||
|
||||
/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph,
|
||||
/// and every mark of the series has the gradient's points at the same place on its own box after the graph is drawn again.
|
||||
#[test]
|
||||
fn a_freeform_gradient_edit_on_a_series_mark_is_kept() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]);
|
||||
// `paint.setFill` stores the gradient before its point is recolored.
|
||||
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap();
|
||||
let fill = fill_of(&s, marks[0]);
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
|
||||
s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap();
|
||||
let edited = fill_of(&s, marks[0]);
|
||||
assert_ne!(edited, fill, "the command recolors the point");
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone()));
|
||||
let Paint::Gradient(g) = &edited else { panic!("{edited:?}") };
|
||||
assert_eq!(g.freeform.as_ref().unwrap().points[1].color.to_hex(), "#00ff00");
|
||||
let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
|
||||
let place = gradient_place(&edited, paint_box_of(&mark, false));
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert!(kept.fill_unit_box);
|
||||
assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place);
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0));
|
||||
series_0_gradients_at(&s, id, false, &place);
|
||||
}
|
||||
|
||||
/// A gradient a graph kept in document coordinates (a file saved before gradients were kept relative to the unit box) is
|
||||
/// drawn on every mark as it was kept.
|
||||
#[test]
|
||||
fn a_gradient_kept_in_document_coordinates_is_drawn_as_kept() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
|
||||
let placed = fill_of(&s, marks[0]);
|
||||
s.edit("Kept in document coordinates", |d, _| {
|
||||
let mut extras = d.node_mut(id).unwrap().extras_mut();
|
||||
let slot = &mut extras.graph.as_mut().unwrap().series_paints[0];
|
||||
(slot.fill, slot.fill_unit_box) = (Some(placed.clone()), false);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0));
|
||||
// The same gradient kept for the fill relative to the unit box leaves the stroke kept in document coordinates as it is.
|
||||
s.edit("Kept fill and stroke", |d, _| {
|
||||
let mut extras = d.node_mut(id).unwrap().extras_mut();
|
||||
let slot = &mut extras.graph.as_mut().unwrap().series_paints[0];
|
||||
(slot.fill, slot.fill_unit_box) = (Some(placed.clone()), true);
|
||||
(slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [3, 4]]})).unwrap();
|
||||
let drawn = marks_of(&s, id, 0);
|
||||
assert!(drawn.iter().all(|m| m.1 == placed), "{drawn:?}");
|
||||
series_0_gradients_at(&s, id, false, &gradient_place(&placed, vectorcraft_doc::GraphicStyle::UNIT_BOX));
|
||||
}
|
||||
|
||||
/// A gradient placed on a series path with no height (a flat line's fill) is kept in document coordinates, and every
|
||||
/// path of the series that takes the kept fill has it at that place in the document.
|
||||
#[test]
|
||||
fn a_gradient_placed_on_a_series_path_with_no_height_is_kept_in_document_coordinates() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 2], [3, 4], [3, 6]]})).unwrap();
|
||||
let flat = |s: &Session| {
|
||||
let parts = series_parts(s, id, 0);
|
||||
parts.into_iter().find(|m| m.appearance.fill_paint().is_none() && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9)).unwrap().id
|
||||
};
|
||||
let line = flat(&s);
|
||||
s.execute("paint.setFill", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "start": [150, 120], "end": [300, 280]})).unwrap();
|
||||
let placed = fill_of(&s, line);
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert!(!kept.fill_unit_box);
|
||||
assert_eq!(kept.fill, Some(placed.clone()));
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1], [3, 5], [3, 2]]})).unwrap();
|
||||
let fills: Vec<Paint> = series_parts(&s, id, 0).iter().map(|m| m.appearance.fill_paint()).filter(|p| matches!(p, Paint::Gradient(_))).collect();
|
||||
assert!(fills.len() >= 2 && fills.iter().all(|p| *p == placed), "{fills:?}");
|
||||
}
|
||||
|
||||
/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series
|
||||
/// keeps relative to the unit box, leaves the kept gradient, the document and its undo history as they are, on every mark.
|
||||
#[test]
|
||||
fn rewriting_a_series_marks_drawn_gradient_stroke_records_no_undo_step() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap();
|
||||
s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1.3, 2], [3.7, 4], [6.1, 1]]})).unwrap();
|
||||
let was = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).collect();
|
||||
assert!(drawn.len() >= 4, "{}", drawn.len());
|
||||
for mark in drawn {
|
||||
let row = top_row(&s, mark.id, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap();
|
||||
assert!(s.doc().unwrap().doc == was, "writing mark {:?} changed the document", mark.id);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
// A mark moved with its gradient shows the kept gradient on its moved box, up to rounding.
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
for (i, mark) in series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).enumerate() {
|
||||
s.execute("select.set", &json!({"ids": [mark.id.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 13.37 + i as f64 * 0.731, "dy": -7.19})).unwrap();
|
||||
let moved = s.doc().unwrap().doc.node(mark.id).unwrap().geometric_bounds().unwrap();
|
||||
assert!((moved.x0 - mark.geometric_bounds().unwrap().x0 - 13.37 - i as f64 * 0.731).abs() < 1e-9, "mark {:?} did not move", mark.id);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let row = top_row(&s, mark.id, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0], kept, "writing moved mark {:?} changed the kept stroke", mark.id);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writing the fill of a series mark with no height (a column for a value of 0) as it is, after the graph drew the mark's
|
||||
/// fill from a gradient the series keeps relative to the unit box, leaves the kept gradient and its unit-box flag as they
|
||||
/// are.
|
||||
#[test]
|
||||
fn rewriting_a_drawn_gradient_fill_on_a_series_mark_with_no_height_keeps_the_kept_gradient() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert!(kept.fill_unit_box);
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[0, 2], [3, 4], [6, 1]]})).unwrap();
|
||||
let flat = series_parts(&s, id, 0)
|
||||
.into_iter()
|
||||
.find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_)) && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9))
|
||||
.unwrap()
|
||||
.id;
|
||||
let changed = s.edit("Write the fill as it is", |d, _| Ok(super::capture_series_top_paints(d, &[(flat, true)]))).unwrap();
|
||||
assert!(!changed);
|
||||
assert_eq!(spec(&s, id).series_paints[0], kept);
|
||||
}
|
||||
|
||||
/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series keeps
|
||||
/// in document coordinates (a file saved before the unit box), leaves the kept gradient and its unit-box flag as they are
|
||||
/// and records no undo step.
|
||||
#[test]
|
||||
fn rewriting_a_series_marks_stroke_drawn_from_a_gradient_kept_in_document_coordinates_keeps_it() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap();
|
||||
s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 2})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap();
|
||||
let placed = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.stroke_paint();
|
||||
s.edit("Kept in document coordinates", |d, _| {
|
||||
let mut extras = d.node_mut(id).unwrap().extras_mut();
|
||||
let slot = &mut extras.graph.as_mut().unwrap().series_paints[0];
|
||||
(slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| m.appearance.stroke_paint() == placed).collect();
|
||||
assert!(drawn.len() >= 3, "{}", drawn.len());
|
||||
for mark in drawn {
|
||||
let row = top_row(&s, mark.id, false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0], kept, "writing mark {:?} changed the kept stroke", mark.id);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient placed on a line series' stroke is kept relative to the line's stroke box, and with Draw Filled Lines the
|
||||
/// band, which takes the kept stroke as its fill, has it at the same place on its own box.
|
||||
#[test]
|
||||
fn a_gradient_kept_for_a_lines_stroke_takes_the_same_place_on_its_filled_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "line");
|
||||
let line = series_parts(&s, id, 0).into_iter().find(|m| m.appearance.fill_paint().is_none()).unwrap().id;
|
||||
s.execute("paint.setStroke", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap();
|
||||
s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "stroke": true, "start": [150, 120], "end": [300, 280]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert!(kept.stroke_unit_box && kept.fill.is_none());
|
||||
let place = gradient_place(kept.stroke.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap();
|
||||
let band = series_parts(&s, id, 0).into_iter().find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_))).unwrap();
|
||||
let at = gradient_place(&band.appearance.fill_paint(), band.geometric_bounds().unwrap());
|
||||
assert!(at.iter().zip(&place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}");
|
||||
}
|
||||
|
||||
/// A series' kept unit-box flags are read from and written to a file, and a file without them reads as document
|
||||
/// coordinates.
|
||||
#[test]
|
||||
fn a_series_paints_unit_box_flags_round_trip() {
|
||||
let kept = vectorcraft_doc::SeriesPaint { fill_unit_box: true, stroke_unit_box: true, ..Default::default() };
|
||||
let text = serde_json::to_string(&kept).unwrap();
|
||||
assert_eq!(text, r#"{"fillUnitBox":true,"strokeUnitBox":true}"#);
|
||||
assert_eq!(serde_json::from_str::<vectorcraft_doc::SeriesPaint>(&text).unwrap(), kept);
|
||||
let old: vectorcraft_doc::SeriesPaint = serde_json::from_str("{}").unwrap();
|
||||
assert!(!old.fill_unit_box && !old.stroke_unit_box);
|
||||
}
|
||||
|
||||
/// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command,
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::{Color, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, TextStyleDef};
|
||||
|
||||
use super::clipboard::SwatchChoices;
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index, ok, point_param, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index_opt, ok, point_param, str_param};
|
||||
use crate::Clipboard;
|
||||
|
||||
/// The extension of a library file.
|
||||
@@ -1649,11 +1649,11 @@ fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.moveGroup";
|
||||
let id = library_id(s, p, C)?;
|
||||
let group = group_param(p, C)?;
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing `to` (an index)"))?;
|
||||
let to = index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing `to` (an index)"))?;
|
||||
let (name, at) = s.libraries.change(&id, |lib| {
|
||||
let i = group_at(lib, group, C)?;
|
||||
let g = lib.groups.remove(i);
|
||||
let at = index(to).min(lib.groups.len());
|
||||
let at = to.min(lib.groups.len());
|
||||
let name = g.name.clone();
|
||||
lib.groups.insert(at, g);
|
||||
Ok((name, at))
|
||||
|
||||
@@ -88,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "artboard.reorder";
|
||||
let i = artboard_index(&s.doc()?.doc, p, C)?;
|
||||
let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing to"))?;
|
||||
let to = super::index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing to"))?;
|
||||
s.edit("Reorder Artboards", |d, _| {
|
||||
if i >= d.artboards.len() {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::draw2::usize_opt;
|
||||
use super::pathops::Beside;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path,
|
||||
has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets,
|
||||
has_selection, ids_param, index_opt, number_or, object_param, ok, str_param, switch_or, targets,
|
||||
};
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -170,7 +170,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<
|
||||
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
|
||||
let a = anchor_from_json(p, "path.appendAnchor")?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
let subpath = index_opt(p, "subpath", "path.appendAnchor")?;
|
||||
s.edit("Pen", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
let si = match subpath {
|
||||
@@ -208,7 +208,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `at` first moves the anchor, its handles with it (the Pen's closing drag with Space held).
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
let subpath = index_opt(p, "subpath", "path.close")?;
|
||||
let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))),
|
||||
@@ -310,7 +310,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
|
||||
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
|
||||
let ai = index_opt(p, "anchor", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
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")?;
|
||||
@@ -337,7 +337,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
|
||||
let ai = index_opt(p, "anchor", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
|
||||
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
|
||||
// An anchor already at (x, y) changes nothing and records no undo step.
|
||||
if let Some(a) = anchor_of(s, id, si, ai)?
|
||||
@@ -453,7 +453,7 @@ pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<Nod
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reverse";
|
||||
let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
let subpath = index_opt(p, "subpath", C)?;
|
||||
let ids = paths_in(&s.doc()?.doc, &targets(s, p)?);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select paths"));
|
||||
@@ -1784,7 +1784,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
|
||||
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
|
||||
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
|
||||
let seg = index_opt(p, "segment", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
|
||||
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
|
||||
let idx = s.edit("Add Anchor Point", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
|
||||
@@ -1724,7 +1724,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut out = json!({ "changed": changed, "swatches": added });
|
||||
not_changed.report(&mut out);
|
||||
if let Some(e) = edit {
|
||||
e.defaults(s);
|
||||
e.defaults(s)?;
|
||||
out["relinked"] = json!(e.relinked());
|
||||
}
|
||||
if converted {
|
||||
|
||||
@@ -1176,6 +1176,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
|
||||
modified: None,
|
||||
notes: Vec::new(),
|
||||
lossy_source: None,
|
||||
kept: serde_json::Map::new(),
|
||||
};
|
||||
let save = fileio::job_for(s, st, plan)?;
|
||||
let meta = Meta {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind, Shared};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index_opt, object_param, ok, str_param, unique_name};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -585,10 +585,10 @@ fn style_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn style_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "graphicStyle.move";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?;
|
||||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `to` (an index)"))?;
|
||||
let to = index_opt(p, "to", cmd)?.ok_or_else(|| bad(cmd, "missing `to` (an index)"))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let from = style_index(d, name)?;
|
||||
let to = index(to).min(d.graphic_styles.len() - 1);
|
||||
let to = to.min(d.graphic_styles.len() - 1);
|
||||
if from == to {
|
||||
return ok();
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(unlinked)
|
||||
})?;
|
||||
map_default_paints(s, &mut unlink);
|
||||
map_default_paints(s, &mut unlink)?;
|
||||
Ok(json!({"deleted": names, "unlinked": unlinked}))
|
||||
}
|
||||
|
||||
@@ -660,20 +660,41 @@ impl Relink {
|
||||
renamed || recolored
|
||||
}
|
||||
|
||||
fn defaults(&self, s: &mut Session) {
|
||||
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t));
|
||||
fn defaults(&self, s: &mut Session) -> Result<()> {
|
||||
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live
|
||||
/// preview (Cancel rolls the document back, not the session).
|
||||
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
|
||||
if s.in_interaction() {
|
||||
return;
|
||||
/// Apply a swatch-link rewrite to the fill and stroke new art takes and to the paints of its
|
||||
/// template appearance, except during a live preview (Cancel rolls the document back, not the
|
||||
/// session). A `command.batch` step rewrites them as the command run on its own does: a failed
|
||||
/// batch, and a canceled batch preview, put back the paints the batch started with. A change is
|
||||
/// recorded with the step just made, so Undo and Redo put these paints back with the document.
|
||||
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()> {
|
||||
if s.in_interaction() && s.batch_stash.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
for p in [&mut s.paint.fill, &mut s.paint.stroke] {
|
||||
p.map_links(f);
|
||||
let start = super::colormgmt::new_art_colors_before(s)?;
|
||||
let pd = &mut s.paint;
|
||||
let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
});
|
||||
let mut changed = false;
|
||||
for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) {
|
||||
changed |= p.map_links(f);
|
||||
}
|
||||
if changed {
|
||||
super::colormgmt::record_new_art_colors(s, start)?;
|
||||
// The step takes the paints the command finally leaves, after the selection's appearance
|
||||
// is inherited.
|
||||
let st = s.doc()?;
|
||||
if st.interaction.is_none() {
|
||||
let uid = st.uid;
|
||||
s.batch_paint_docs.push(uid);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a swatch-link rewrite to the document ([`Document::map_solid_paints`]) and to the art of
|
||||
@@ -888,7 +909,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(relinked)
|
||||
})?;
|
||||
relink.defaults(s);
|
||||
relink.defaults(s)?;
|
||||
Ok(json!({"name": to, "relinked": relinked}))
|
||||
}
|
||||
|
||||
@@ -1084,7 +1105,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
|
||||
Ok(relinked)
|
||||
})?;
|
||||
map_default_paints(s, &mut relink);
|
||||
map_default_paints(s, &mut relink)?;
|
||||
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
|
||||
}
|
||||
|
||||
@@ -1141,7 +1162,7 @@ fn swatch_spot_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let relinked = map_swatch_links(d, &mut follow_bases(&bases));
|
||||
Ok((relinked, bases))
|
||||
})?;
|
||||
map_default_paints(s, &mut follow_bases(&bases));
|
||||
map_default_paints(s, &mut follow_bases(&bases))?;
|
||||
Ok(json!({"useLab": use_lab, "relinked": relinked}))
|
||||
}
|
||||
|
||||
@@ -1165,8 +1186,11 @@ pub(super) struct GroupEdit {
|
||||
|
||||
impl GroupEdit {
|
||||
/// Apply the link changes to the default fill and stroke for new art.
|
||||
pub(super) fn defaults(&self, s: &mut Session) {
|
||||
self.relinks.iter().for_each(|r| r.defaults(s));
|
||||
pub(super) fn defaults(&self, s: &mut Session) -> Result<()> {
|
||||
if self.relinks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
map_default_paints(s, &mut |c, l, t| self.relinks.iter().any(|r| r.apply(c, l, t)))
|
||||
}
|
||||
|
||||
/// How many paints linked to the group's swatches the rewrite changed.
|
||||
@@ -1242,6 +1266,6 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
colors.into_iter().enumerate().map(|(i, c)| if old.get(i) == Some(&Some(c)) { c } else { super::paint::new_color(s, p, c) }).collect();
|
||||
let rename = str_param(p, "rename");
|
||||
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
|
||||
e.defaults(s);
|
||||
e.defaults(s)?;
|
||||
Ok(json!({"name": e.name, "swatches": e.swatches, "relinked": e.relinked}))
|
||||
}
|
||||
|
||||
@@ -980,7 +980,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(added)
|
||||
})?;
|
||||
if replace {
|
||||
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l));
|
||||
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l))?;
|
||||
}
|
||||
Ok(json!({ "added": added }))
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{length_given, number_given, shape_node};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param,
|
||||
ok, str_param, switch_or, targets, text_param,
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, index_opt, number_or,
|
||||
object_param, ok, str_param, switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -1376,7 +1376,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setPathHandle";
|
||||
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
|
||||
let ai = index_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "missing anchor"))?;
|
||||
let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in");
|
||||
let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let independent = switch_or(p, "independent", false, C)?;
|
||||
|
||||
@@ -1688,10 +1688,9 @@ fn warichu_is_a_character_attribute_set_saved_and_exported() {
|
||||
assert!(["warichu", "warichuLines", "warichuGap", "warichuScale", "warichuAlign"].iter().all(|k| saved.get(*k).is_none()), "{saved}");
|
||||
let plain_svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
let plain_pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
||||
let text_ops = |pdf: &[u8]| {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
s.contains("Tj") || s.contains("TJ")
|
||||
};
|
||||
// Only text objects count: the file ID in the trailer and the XMP is base64 that changes each
|
||||
// second and now and then spells `Tj` or `TJ`.
|
||||
let text_ops = |pdf: &[u8]| String::from_utf8_lossy(pdf).lines().any(|l| l.starts_with("BT ") && (l.contains("Tj") || l.contains("TJ")));
|
||||
assert!(plain_svg.contains("<text") && text_ops(&plain_pdf));
|
||||
|
||||
// text.setFormat with the values the characters already have changes nothing and records no
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste without Formatting",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+V"),
|
||||
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind → {ids, added, merged, renamed}",
|
||||
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
paste_plain
|
||||
),
|
||||
@@ -1460,6 +1460,16 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) {
|
||||
t.set_all_paras(Default::default());
|
||||
refresh_bounds(t);
|
||||
}
|
||||
// A graph keeps the names of its labels' character and paragraph styles for each redraw.
|
||||
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
|
||||
let l = &mut g.label_styles;
|
||||
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
|
||||
st.style_name = None;
|
||||
}
|
||||
for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() {
|
||||
pa.style_name = None;
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
strip_formatting(std::sync::Arc::make_mut(c));
|
||||
|
||||
@@ -369,12 +369,13 @@ impl DocState {
|
||||
/// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the
|
||||
/// answer is kept for this version of the document and those artboards, and a change of the
|
||||
/// selection or the view keeps it. It is measured again when what the measure reads outside the
|
||||
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts,
|
||||
/// which set the bounds of all type.
|
||||
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts and
|
||||
/// the hyphenation exceptions, which set the bounds of all type.
|
||||
pub fn any_art_to_fit(&self, indices: &[usize]) -> bool {
|
||||
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation()))
|
||||
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::layout_inputs()))
|
||||
}
|
||||
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts.
|
||||
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the layout inputs
|
||||
/// ([`vectorcraft_text::layout_inputs`]).
|
||||
pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool {
|
||||
// Measuring runs renderer and plug-in code; should it ever ask again while measuring, it
|
||||
// is measured without being kept rather than borrowed twice.
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
//! Before a run starts, its preprocessor lines are read ([`include`]): included files take the
|
||||
//! place of their lines, and an error in one is reported at its own file, line and column.
|
||||
//! `$.evalFile(path)` runs another file in the same global environment and gives its last value,
|
||||
//! `$.fileName` is the file being run and `$.includePath` the `#includepath` list.
|
||||
//! `$.fileName` is the file of the code that reads it (the file being run for code that `eval` or
|
||||
//! `Function` compiled) and `$.includePath` the `#includepath` list.
|
||||
//!
|
||||
//! A run writes to the Script Console with `$.write` and `$.writeln`, and `vc.log` adds a line
|
||||
//! there too. The session's console gets a start line, the run's output, its uncaught error, an end
|
||||
|
||||
@@ -25,9 +25,11 @@ impl Session {
|
||||
// Clicks find an image's own fills and strokes where their effects draw them.
|
||||
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
|
||||
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
|
||||
// evaluated again when the fonts or the plug-ins change.
|
||||
// evaluated again when the fonts, the hyphenation exceptions or the plug-ins change.
|
||||
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
|
||||
vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
|
||||
let mut h = std::hash::DefaultHasher::new();
|
||||
std::hash::Hash::hash(&(vectorcraft_text::layout_inputs(), vectorcraft_render::effects::plugin_revision()), &mut h);
|
||||
std::hash::Hasher::finish(&h)
|
||||
});
|
||||
// Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
|
||||
@@ -560,6 +560,125 @@ fn reset_bounding_box_then_release_intertwine() {
|
||||
reset_then_release("object.intertwine.make", "object.intertwine.release");
|
||||
}
|
||||
|
||||
/// A symbol of two rectangles, the first turned `inner` degrees before the symbol is made, placed
|
||||
/// as an instance that is then turned 20°.
|
||||
fn turned_instance(inner: f64) -> (Session, NodeId) {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||||
let b = rect(&mut s, 150.0, 220.0, 100.0, 50.0);
|
||||
if inner != 0.0 {
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.rotate", &json!({"angle": inner})).unwrap();
|
||||
}
|
||||
sel(&mut s, &[a, b]);
|
||||
let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap());
|
||||
sel(&mut s, &[inst]);
|
||||
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
|
||||
assert!(near(angle(&s, inst), 20.0));
|
||||
(s, inst)
|
||||
}
|
||||
|
||||
/// Break Link and Expand with the parameters that turn instance `inst` into its art.
|
||||
fn expand_instance(s: &mut Session, command: &str, inst: NodeId) {
|
||||
sel(s, &[inst]);
|
||||
let params = if command == "symbol.breakLink" { json!({"ids": [inst.0]}) } else { json!({"object": true, "fill": false, "stroke": false}) };
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(inst).is_none_or(|n| !matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })), "{command}");
|
||||
}
|
||||
|
||||
/// The angles of the objects inside the document's only top-level object, that object included.
|
||||
fn art_angles(s: &Session) -> Vec<f64> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let layer = d.layers.first().unwrap();
|
||||
let top = layer.children().unwrap();
|
||||
assert_eq!(top.len(), 1);
|
||||
let mut out = vec![];
|
||||
subtree_angles(&top[0], &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_then_expand_symbol_instance() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
let (mut s, inst) = turned_instance(0.0);
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(angle(&s, inst), 0.0);
|
||||
expand_instance(&mut s, command, inst);
|
||||
let angles = art_angles(&s);
|
||||
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(selection_box(&s).angle, 0.0, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_a_turned_symbol_instance_keeps_its_angle() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
let (mut s, inst) = turned_instance(0.0);
|
||||
expand_instance(&mut s, command, inst);
|
||||
let angles = art_angles(&s);
|
||||
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 20.0)), "{command}: {angles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_then_expand_keeps_the_angles_inside_the_symbol() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
let (mut s, inst) = turned_instance(30.0);
|
||||
s.execute("object.resetBoundingBox", &json!({})).unwrap();
|
||||
expand_instance(&mut s, command, inst);
|
||||
// The turned rectangle keeps the 30° it had in the symbol; the rest stand square.
|
||||
let mut angles = art_angles(&s);
|
||||
angles.sort_by(f64::total_cmp);
|
||||
assert!(near(*angles.last().unwrap(), 30.0) && angles[..angles.len() - 1].iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The group that an instance's opacity adds around the art of a one-object symbol stands as the
|
||||
/// instance's box does, turned or reset.
|
||||
#[test]
|
||||
fn expanding_a_faded_symbol_instance_gives_its_group_the_instance_box() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
for reset in [false, true] {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||||
sel(&mut s, &[a]);
|
||||
let inst = id_of(&s.execute("symbol.new", &json!({"name": "One"})).unwrap());
|
||||
sel(&mut s, &[inst]);
|
||||
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
|
||||
s.execute("object.setProps", &json!({"ids": [inst.0], "opacity": 50})).unwrap();
|
||||
if reset {
|
||||
s.execute("object.resetBoundingBox", &json!({})).unwrap();
|
||||
}
|
||||
expand_instance(&mut s, command, inst);
|
||||
let want = if reset { 0.0 } else { 20.0 };
|
||||
let angles = art_angles(&s);
|
||||
assert!(angles.len() == 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mirrored instance: turned 170° and then reflected, its box stands 180° from the box its
|
||||
/// transform gives in one step. The art takes the instance's box, and stands square after a reset.
|
||||
#[test]
|
||||
fn expanding_a_reflected_symbol_instance_keeps_its_box() {
|
||||
for command in ["symbol.breakLink", "object.expand"] {
|
||||
for reset in [false, true] {
|
||||
let (mut s, inst) = turned_instance(0.0);
|
||||
s.execute("object.rotate", &json!({"angle": 150})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert!(near(angle(&s, inst), -170.0), "{}", angle(&s, inst));
|
||||
if reset {
|
||||
s.execute("object.resetBoundingBox", &json!({})).unwrap();
|
||||
}
|
||||
let want = angle(&s, inst);
|
||||
expand_instance(&mut s, command, inst);
|
||||
let angles = art_angles(&s);
|
||||
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_then_release_linked_opacity_mask() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -468,6 +468,72 @@ fn styles_symbols_and_images_of_the_same_name_are_renamed() {
|
||||
assert_eq!(key(&s, img2), src_key);
|
||||
}
|
||||
|
||||
/// A graph whose series fill, value labels and legend rows paint with global swatch "Brand" and
|
||||
/// whose value labels are set in character style "Emphasis" and paragraph style "Body", as a
|
||||
/// later redraw would give them to its marks and labels.
|
||||
fn styled_graph(s: &mut Session) -> NodeId {
|
||||
s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff0000", "global": true})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"space_after": 8.0}})).unwrap();
|
||||
let v = s.execute("graph.create", &json!({"type": "column", "x": 200, "y": 200, "width": 60, "height": 40, "csv": ",2024\nQ1,3\nQ2,5"}));
|
||||
let id = id_of(&v.unwrap());
|
||||
let brand = linked(hex("#ff0000"), "Brand", 1.0);
|
||||
let mut extras = doc_mut(s).node_mut(id).unwrap().extras_mut();
|
||||
let g = extras.graph.as_mut().unwrap();
|
||||
g.series_paints = vec![vectorcraft_doc::SeriesPaint { fill: Some(brand.clone()), ..Default::default() }];
|
||||
let l = &mut g.label_styles;
|
||||
l.values = Some(vectorcraft_doc::CharStyle { fill: brand.clone(), style_name: Some("Emphasis".into()), ..Default::default() });
|
||||
l.values_para = Some(vectorcraft_doc::ParaStyle { style_name: Some("Body".into()), ..Default::default() });
|
||||
let rows = vectorcraft_doc::SeriesPaint { stroke: Some(brand), ..Default::default() };
|
||||
l.legend_rows = Some(vectorcraft_doc::graph::LabelRows { paint: rows, ..Default::default() });
|
||||
id
|
||||
}
|
||||
|
||||
/// The swatch names a graph's stored paints link to, and its value labels' character and
|
||||
/// paragraph style names.
|
||||
fn graph_links(s: &Session, id: NodeId) -> (Vec<String>, Option<String>, Option<String>) {
|
||||
let n = node(s, id);
|
||||
let swatches = vectorcraft_doc::swatches::graph_paints(&n).filter_map(|p| match p {
|
||||
Paint::Solid { swatch: Some(w), .. } => Some(w.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let l = &n.extras().graph.as_deref().unwrap().label_styles;
|
||||
let values = l.values.as_ref().and_then(|st| st.style_name.clone());
|
||||
(swatches.collect(), values, l.values_para.as_ref().and_then(|pa| pa.style_name.clone()))
|
||||
}
|
||||
|
||||
/// A pasted graph brings the swatches and text styles its series and labels keep, and follows
|
||||
/// them when they come in under new names.
|
||||
#[test]
|
||||
fn a_pasted_graph_brings_and_follows_its_label_styles_and_paints() {
|
||||
let mut s = session();
|
||||
let g = styled_graph(&mut s);
|
||||
copy(&mut s, &[g]);
|
||||
|
||||
new_doc(&mut s);
|
||||
let v = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert!(d.swatch("Brand").is_some(), "{v}");
|
||||
assert!(d.char_styles.iter().any(|x| x.name == "Emphasis") && d.para_styles.iter().any(|x| x.name == "Body"), "{v}");
|
||||
let brand = vec!["Brand".to_string(); 3];
|
||||
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand.clone(), Some("Emphasis".into()), Some("Body".into())));
|
||||
|
||||
// Into a document that gives each name something else: they come in renamed.
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 8.0}})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"space_after": 2.0}})).unwrap();
|
||||
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap();
|
||||
let brand2 = vec!["Brand 2".to_string(); 3];
|
||||
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand2, Some("Emphasis 2".into()), Some("Body 2".into())), "{v}");
|
||||
|
||||
// Without formatting: the labels' text styles stay behind.
|
||||
new_doc(&mut s);
|
||||
let v = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
|
||||
assert!(doc(&s).char_styles.is_empty() && doc(&s).para_styles.is_empty(), "{v}");
|
||||
assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_again_reuses_what_an_earlier_paste_added() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -184,6 +184,25 @@ fn pencil_alt_closes_path() {
|
||||
assert!(ps[0].1.is_closed());
|
||||
}
|
||||
|
||||
/// A Pencil stroke drawn as a dome and closed with Alt at the release closes with a straight segment
|
||||
/// across the bottom, from where the stroke ends to where it starts: the path stays within where the
|
||||
/// pen went, rather than curving on past both ends and back like a hat's brim.
|
||||
#[test]
|
||||
fn pencil_alt_closes_a_stroke_straight_back_to_its_start() {
|
||||
let mut s = session();
|
||||
let pts: Vec<(f64, f64)> =
|
||||
(0..=60).map(|i| f64::from(i) * std::f64::consts::PI / 60.0).map(|a| (300.0 - 150.0 * a.cos(), 400.0 - 200.0 * a.sin())).collect();
|
||||
drag(&mut s, "pencil", &pts, Mods { alt: true, ..Default::default() });
|
||||
let ps = paths(&s);
|
||||
let sp = &ps[0].1.subpaths[0];
|
||||
assert!(sp.closed);
|
||||
let b = ps[0].1.bounds().unwrap();
|
||||
assert!(b.x0 > 149.5 && b.x1 < 450.5 && b.y1 < 400.5, "within the stroke: {b:?}");
|
||||
let (first, last) = (sp.anchors.first().unwrap(), sp.anchors.last().unwrap());
|
||||
assert!(first.p.distance(Point::new(150.0, 400.0)) < 1e-6 && last.p.distance(Point::new(450.0, 400.0)) < 1e-6, "{first:?} {last:?}");
|
||||
assert_eq!((last.h_out, first.h_in), (last.p, first.p), "a straight closing segment");
|
||||
}
|
||||
|
||||
/// #3799: with Shift held, a wavy Pencil drag draws one straight segment at 45° steps from the
|
||||
/// Constrain Angle (here 30°: a drag at about 60° comes out at 75°).
|
||||
#[test]
|
||||
|
||||
@@ -422,6 +422,122 @@ fn a_compound_clipping_path_in_the_art_keeps_its_shape() {
|
||||
assert_eq!(seen, (true, true));
|
||||
}
|
||||
|
||||
/// A Drop Shadow 20 pt to the right on row `row` of instance `id`, solid black with no blur.
|
||||
fn shadow_row(s: &mut Session, id: NodeId, row: usize) {
|
||||
let params = json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal"});
|
||||
run(s, "effect.apply", json!({"ids": [id.0], "item": row, "effect": "stylize.dropShadow", "params": params}));
|
||||
}
|
||||
|
||||
/// Is `p` dark: a shadow, not the white page or the red fill?
|
||||
fn dark(p: [u8; 4]) -> bool {
|
||||
p[..3].iter().all(|c| *c < 100)
|
||||
}
|
||||
|
||||
/// A raster effect on a symbol instance's fill draws on the filled shapes of its art: the shadow
|
||||
/// falls right of the square, the bounds take it in, a hidden fill draws none, and Expand
|
||||
/// Appearance keeps the look in one undo step.
|
||||
#[test]
|
||||
fn a_symbol_instance_fill_shadow_shadows_its_filled_art() {
|
||||
let mut s = session();
|
||||
let (id, row) = instance_with_row(&mut s, "Square", true);
|
||||
assert!(near(render(&s).pixel(160, 100), WHITE));
|
||||
shadow_row(&mut s, id, row);
|
||||
let before = render(&s);
|
||||
assert!(dark(before.pixel(165, 100)), "the fill's shadow falls right of the square: {:?}", before.pixel(165, 100));
|
||||
assert!(near(before.pixel(100, 100), RED), "the art draws over its shadow: {:?}", before.pixel(100, 100));
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds");
|
||||
assert!(b.x1 >= 169.0, "the bounds take in the shadow: {b:?}");
|
||||
// A view that shows only the shadow, right of the square: the instance is still drawn.
|
||||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let right = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::translate((-155.0, 0.0)), &opts);
|
||||
assert!(dark(right.pixel(10, 100)), "the shadow past the square's box is drawn: {:?}", right.pixel(10, 100));
|
||||
|
||||
assert!(expandable(&s), "the fill's shadow can be expanded");
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let ch = node(&s, id).children().cloned().expect("the instance becomes a group");
|
||||
assert!(matches!(ch[0].kind, NodeKind::Image(_)), "the shadow is an image under the art: {}", ch[0].kind_label());
|
||||
assert!(matches!(ch[1].kind, NodeKind::SymbolInstance { .. }), "the art stays an instance: {}", ch[1].kind_label());
|
||||
no_effects_left(&ch[1]);
|
||||
let after = render(&s);
|
||||
assert!(differing(&before, &after, 24) < 400, "expanding changed {} pixels", differing(&before, &after, 24));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }), "one undo gives the instance back");
|
||||
assert!(n.appearance.items[row].effects().iter().any(|e| e.id == "stylize.dropShadow"));
|
||||
|
||||
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "visible": false}));
|
||||
assert!(near(render(&s).pixel(165, 100), WHITE), "a hidden fill draws no shadow");
|
||||
}
|
||||
|
||||
/// A raster effect on a symbol instance's stroke draws on the stroked shapes of its art only: the
|
||||
/// stroke's shadow falls right of the stroke, and none falls where only the fill would cast one.
|
||||
#[test]
|
||||
fn a_symbol_instance_stroke_shadow_shadows_only_its_stroked_art() {
|
||||
let mut s = session();
|
||||
let (id, row) = instance_with_row(&mut s, "Square", false);
|
||||
shadow_row(&mut s, id, row);
|
||||
let r = render(&s);
|
||||
assert!(dark(r.pixel(170, 100)), "the stroke's shadow falls right of the stroke: {:?}", r.pixel(170, 100));
|
||||
assert!(near(r.pixel(160, 100), WHITE), "the fill casts no shadow: {:?}", r.pixel(160, 100));
|
||||
}
|
||||
|
||||
/// Geometry and raster effects on one fill of an instance: the shadow falls from the grown fill.
|
||||
#[test]
|
||||
fn a_symbol_instance_fill_shadow_follows_the_fill_offset_before_it() {
|
||||
let mut s = session();
|
||||
let (id, row) = instance_with_row(&mut s, "Square", true);
|
||||
offset_row(&mut s, id, row, 10.0);
|
||||
shadow_row(&mut s, id, row);
|
||||
let r = render(&s);
|
||||
assert!(near(r.pixel(44, 100), RED), "the fill grows by the offset: {:?}", r.pixel(44, 100));
|
||||
assert!(dark(r.pixel(175, 100)), "the shadow falls from the grown fill: {:?}", r.pixel(175, 100));
|
||||
}
|
||||
|
||||
/// An instance inside a symbol's art passes the outer instance's fill shadow on to its own art.
|
||||
#[test]
|
||||
fn a_nested_symbol_instance_takes_the_outer_fill_shadow() {
|
||||
let mut s = session();
|
||||
square(&mut s);
|
||||
run(&mut s, "symbol.new", json!({"name": "Inner"}));
|
||||
let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Outer"}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
|
||||
let row = node(&s, id).appearance.items.len() - 1;
|
||||
run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true}));
|
||||
shadow_row(&mut s, id, row);
|
||||
assert!(dark(render(&s).pixel(165, 100)), "the inner art casts the shadow: {:?}", render(&s).pixel(165, 100));
|
||||
}
|
||||
|
||||
/// Export draws a symbol instance's fill shadow on the filled shapes of its art: baking leaves the
|
||||
/// shadow on the art's fill, not its stroke, and PDF export makes an image of it.
|
||||
#[test]
|
||||
fn export_draws_a_symbol_instance_fill_shadow() {
|
||||
let mut s = session();
|
||||
let (id, row) = instance_with_row(&mut s, "Square", true);
|
||||
shadow_row(&mut s, id, row);
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let baked = vectorcraft_render::effects::bake_document(&doc).expect("the instance is baked");
|
||||
let n = baked.node(id).expect("the baked art keeps the instance's id").clone();
|
||||
assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. }));
|
||||
let shadowed = |i: &vectorcraft_doc::AppearanceItem| i.effects().iter().any(|e| e.id == "stylize.dropShadow");
|
||||
let (mut fills, mut strokes) = (0, 0);
|
||||
n.walk(&mut |part| {
|
||||
fills += part.appearance.items.iter().filter(|i| i.is_fill() && shadowed(i)).count();
|
||||
strokes += part.appearance.items.iter().filter(|i| !i.is_fill() && shadowed(i)).count();
|
||||
});
|
||||
assert_eq!((fills, strokes), (1, 0), "the art's fill carries the shadow");
|
||||
|
||||
let flat = crate::flatten_raster_effects(&doc).expect("the shadow becomes an image");
|
||||
let mut image = None;
|
||||
flat.node(id).expect("the flattened art keeps the instance's id").walk(&mut |part| {
|
||||
if matches!(part.kind, NodeKind::Image(_)) {
|
||||
image = part.geometric_bounds();
|
||||
}
|
||||
});
|
||||
let image = image.expect("the shadow is an image");
|
||||
assert!(image.x1 >= 165.0, "the image holds the shadow right of the square: {image:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -138,7 +138,7 @@ fn knockout_settings_round_trip_and_old_bools_load() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_and_svg_write_knockout_groups_as_masks() {
|
||||
fn pdf_writes_knockout_groups_and_svg_writes_them_as_masks() {
|
||||
let mut s = session();
|
||||
let g = knockout_group(&mut s);
|
||||
let plain = s.doc().unwrap().doc.clone();
|
||||
@@ -149,10 +149,11 @@ fn pdf_and_svg_write_knockout_groups_as_masks() {
|
||||
let r = pdf(&plain);
|
||||
assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty());
|
||||
let r = pdf(&ko);
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
// The lower rectangle is drawn through a luminosity mask made of the upper one's alpha.
|
||||
assert!(text.contains("/Luminosity") && text.contains("/Alpha"), "soft masks");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings);
|
||||
let text = String::from_utf8_lossy(&r.bytes).replace(' ', "");
|
||||
// The rectangles knock out the area they paint: a PDF knockout group, without soft masks.
|
||||
assert_eq!(text.matches("/Ktrue").count(), 1, "a knockout group");
|
||||
assert!(!text.contains("/Luminosity"), "no soft masks");
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
|
||||
let svg = |d: &Document| vectorcraft_svg::export(d, &Default::default());
|
||||
assert!(!svg(&plain).contains("knockout"));
|
||||
@@ -173,5 +174,5 @@ fn pdf_and_svg_write_knockout_groups_as_masks() {
|
||||
assert!(svg(&page).contains("isolation"));
|
||||
page.page_knockout = true;
|
||||
assert_eq!(svg(&page).matches("mask=\"url(#knockout-").count(), 1, "the layer is neutral: its objects knock each other out");
|
||||
assert!(String::from_utf8_lossy(&pdf(&page).bytes).contains("/Luminosity"));
|
||||
assert!(String::from_utf8_lossy(&pdf(&page).bytes).replace(' ', "").contains("/Ktrue"));
|
||||
}
|
||||
|
||||
@@ -572,6 +572,64 @@ fn a_malformed_required_number_is_not_reported_as_missing() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
/// A required whole number given as something other than a whole number of 0 or more (`"3"`, `2.5`,
|
||||
/// `-1`, `true`, `[]`, `{}`) is refused as the wrong type, naming its key, not as a missing integer; one
|
||||
/// left out or given as null is still missing. An optional one left out or null keeps its default.
|
||||
/// Neither changes the document or records an undo step.
|
||||
#[test]
|
||||
fn a_malformed_required_integer_is_not_reported_as_missing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let with = |base: &serde_json::Value, key: &str, v: Option<serde_json::Value>| {
|
||||
let mut p = base.clone();
|
||||
let o = p.as_object_mut().unwrap();
|
||||
match v {
|
||||
Some(v) => o.insert(key.into(), v),
|
||||
None => o.remove(key),
|
||||
};
|
||||
p
|
||||
};
|
||||
let refused = |s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str| match s.execute(cmd, p) {
|
||||
Err(EngineError::BadParams { cmd: c, msg }) => assert_eq!((c.as_str(), msg.as_str()), (cmd, want), "{p}"),
|
||||
other => panic!("{cmd} {p}: {other:?}"),
|
||||
};
|
||||
let ends = json!({"sourceDocument": 1, "grab": [0, 0], "at": [0, 0], "ids": [1]});
|
||||
let cases = [
|
||||
("appearance.setItem", json!({"index": 0}), "index", "missing integer `index`"),
|
||||
("path.removeAnchor", json!({"id": 1, "anchor": 0}), "anchor", "missing integer `anchor`"),
|
||||
("path.split", json!({"id": 1, "anchor": 0}), "anchor", "need `anchor` or `segment` + `t`"),
|
||||
("addon.pen.removePoint", json!({"anchor": 0}), "anchor", "missing integer `anchor`"),
|
||||
("edit.dropSelection", ends, "sourceDocument", "missing integer `sourceDocument`"),
|
||||
("document.activate", json!({"index": 0}), "index", "missing index"),
|
||||
("document.move", json!({"to": 0}), "to", "missing `to`: a non-negative integer"),
|
||||
];
|
||||
let malformed = [json!("3"), json!(2.5), json!(-1), json!(true), json!([]), json!({})];
|
||||
for (cmd, base, key, missing) in &cases {
|
||||
for v in &malformed {
|
||||
refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
|
||||
}
|
||||
for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
|
||||
refused(&mut s, cmd, &p, missing);
|
||||
}
|
||||
}
|
||||
// An optional `subpath` given as anything else is refused the same way; left out or null, it is
|
||||
// subpath 0 and the command goes on to read `anchor`.
|
||||
let base = json!({"id": 1, "anchor": "3"});
|
||||
for v in &malformed {
|
||||
refused(&mut s, "path.removeAnchor", &with(&base, "subpath", Some(v.clone())), "`subpath` must be a whole number");
|
||||
}
|
||||
for p in [with(&base, "subpath", None), with(&base, "subpath", Some(json!(null)))] {
|
||||
refused(&mut s, "path.removeAnchor", &p, "`anchor` must be a whole number");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
// A whole JSON number is a whole number.
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
}
|
||||
|
||||
/// A command that takes the keys of one that isn't checked yet isn't checked either.
|
||||
#[test]
|
||||
fn a_reference_to_an_unchecked_command_is_unchecked() {
|
||||
@@ -791,3 +849,75 @@ fn journal_entries_pass_the_check() {
|
||||
assert!(check_params(id, params).is_ok(), "{id} {params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An index of a path, type, panel, library, style or ruler command given as something other than a
|
||||
/// whole number of 0 or more (`"3"`, `2.5`, `-1`, `true`, `[]`, `{}`) is refused as the wrong type,
|
||||
/// naming its key. A required one is not reported as missing, and an optional one (`subpath` of
|
||||
/// `path.appendAnchor`, `path.close` and `path.reverse`, `artboard` of the ruler origin commands) is
|
||||
/// not read as its default. A required one left out or null is still missing, and an optional one left
|
||||
/// out or null keeps its default. Neither changes the document or records an undo step.
|
||||
#[test]
|
||||
fn a_malformed_index_is_not_missing_or_its_default() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("path.create", &json!({"d": "M0 0 L10 0 M20 0 L30 0"})).unwrap()["id"].clone();
|
||||
s.execute("library.create", &json!({"name": "Brand"})).unwrap();
|
||||
s.execute("library.createGroup", &json!({"name": "Warm"})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let with = |base: &serde_json::Value, key: &str, v: Option<serde_json::Value>| {
|
||||
let mut p = base.clone();
|
||||
let o = p.as_object_mut().unwrap();
|
||||
match v {
|
||||
Some(v) => o.insert(key.into(), v),
|
||||
None => o.remove(key),
|
||||
};
|
||||
p
|
||||
};
|
||||
let refused = |s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str| match s.execute(cmd, p) {
|
||||
Err(EngineError::BadParams { cmd: c, msg }) => assert_eq!((c.as_str(), msg.as_str()), (cmd, want), "{p}"),
|
||||
other => panic!("{cmd} {p}: {other:?}"),
|
||||
};
|
||||
let malformed = [json!("3"), json!(2.5), json!(-1), json!(true), json!([]), json!({})];
|
||||
let handle = json!({"id": id, "x": 5, "y": 5});
|
||||
let required = [
|
||||
("path.setHandle", handle.clone(), "anchor", "missing anchor"),
|
||||
("path.moveAnchor", handle.clone(), "anchor", "missing anchor"),
|
||||
("path.insertAnchor", json!({"id": id}), "segment", "missing segment"),
|
||||
("text.setPathHandle", handle.clone(), "anchor", "missing anchor"),
|
||||
("artboard.reorder", json!({"index": 0}), "to", "missing to"),
|
||||
("library.moveGroup", json!({"group": "Warm"}), "to", "missing `to` (an index)"),
|
||||
("graphicStyle.move", json!({"name": "Default"}), "to", "missing `to` (an index)"),
|
||||
];
|
||||
for (cmd, base, key, missing) in &required {
|
||||
for v in &malformed {
|
||||
refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
|
||||
}
|
||||
for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
|
||||
refused(&mut s, cmd, &p, missing);
|
||||
}
|
||||
}
|
||||
let optional = [
|
||||
("path.appendAnchor", handle.clone(), "subpath"),
|
||||
("path.close", json!({"id": id}), "subpath"),
|
||||
("path.reverse", json!({"ids": [id]}), "subpath"),
|
||||
("document.rulerOrigin", json!({}), "artboard"),
|
||||
("document.setRulerOrigin", json!({"x": 5, "y": 5}), "artboard"),
|
||||
("document.resetRulerOrigin", json!({}), "artboard"),
|
||||
];
|
||||
for (cmd, base, key) in &optional {
|
||||
for v in &malformed {
|
||||
refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number"));
|
||||
}
|
||||
}
|
||||
// A whole number that names no artboard is still out of range.
|
||||
refused(&mut s, "document.resetRulerOrigin", &json!({"artboard": 1}), "index out of range");
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
// Left out or null, an optional one keeps its default.
|
||||
for (cmd, base, key) in &optional {
|
||||
for p in [with(base, key, None), with(base, key, Some(json!(null)))] {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,7 +693,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
("blobBrush", Cursor::BlobBrush),
|
||||
("shaper", Cursor::Shaper),
|
||||
("pencil", Cursor::PencilNew),
|
||||
("smooth", Cursor::Smooth),
|
||||
("pathEraser", Cursor::PathEraser),
|
||||
("join", Cursor::Join),
|
||||
("eraser", Cursor::Eraser),
|
||||
@@ -738,6 +737,27 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With no selected path, the Smooth tool shows Not Allowed, and Use Precise Cursors leaves it so; with a
|
||||
/// selected path, it shows its own pointer, or a crosshair with the preference on.
|
||||
#[test]
|
||||
fn use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer() {
|
||||
use vectorcraft_tools::{Cursor, Mods};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let at = vectorcraft_geom::Point::new(300.0, 300.0);
|
||||
s.select_tool("smooth", ViewInfo::default()).unwrap();
|
||||
for precise in [false, true] {
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
|
||||
assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), Cursor::NotAllowed, "precise {precise}");
|
||||
}
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
for (precise, c) in [(false, Cursor::Smooth), (true, Cursor::Crosshair)] {
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap();
|
||||
assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), c, "precise {precise}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors
|
||||
/// turns those into a crosshair too.
|
||||
#[test]
|
||||
|
||||
@@ -1075,6 +1075,146 @@ fn deleting_a_global_swatch_unlinks_art_that_keeps_its_colour() {
|
||||
assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand"));
|
||||
}
|
||||
|
||||
/// A session whose default fill and stroke for new art are linked to the global swatch `Brand`
|
||||
/// (`#2a6fb0`), in color group `G`, beside the global swatch `Other` (`#ff8800`).
|
||||
fn brand_defaults_session() -> Session {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true}));
|
||||
run(&mut s, "swatch.newGroup", json!({"name": "G", "swatches": ["Brand"]}));
|
||||
run(&mut s, "swatch.new", json!({"name": "Other", "color": "#ff8800", "global": true}));
|
||||
run(&mut s, "select.set", json!({"ids": []}));
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Brand"}));
|
||||
run(&mut s, "paint.setStroke", json!({"swatch": "Brand"}));
|
||||
assert_eq!(s.paint.fill, linked("#2a6fb0", "Brand"));
|
||||
s
|
||||
}
|
||||
|
||||
fn fill_and_stroke(s: &Session, id: NodeId) -> (Paint, Paint) {
|
||||
let a = &doc(s).node(id).unwrap().appearance;
|
||||
(a.fill_paint(), a.stroke_paint())
|
||||
}
|
||||
|
||||
/// Run `steps` and then draw a rectangle, once one command at a time and once as one
|
||||
/// `command.batch`, and check that both leave the same default paints and give the rectangle drawn
|
||||
/// in the batch, and one drawn after it, the same fill and stroke. Returns the batch's rectangle's
|
||||
/// fill and stroke.
|
||||
fn batch_matches_steps(steps: &[(&str, Value)]) -> (Paint, Paint) {
|
||||
let shape = ("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||||
let mut one_by_one = brand_defaults_session();
|
||||
for (id, p) in steps {
|
||||
run(&mut one_by_one, id, p.clone());
|
||||
}
|
||||
let single = rect(&mut one_by_one);
|
||||
let mut batched = brand_defaults_session();
|
||||
let commands: Vec<Value> = steps.iter().chain([&shape]).map(|(id, p)| json!({"command": id, "params": p})).collect();
|
||||
let r = run(&mut batched, "command.batch", json!({ "commands": commands }));
|
||||
let in_batch = NodeId(r["results"][steps.len()]["id"].as_u64().unwrap());
|
||||
assert_eq!(batched.paint.fill, one_by_one.paint.fill, "the default fill after {steps:?}");
|
||||
assert_eq!(batched.paint.stroke, one_by_one.paint.stroke, "the default stroke after {steps:?}");
|
||||
let expected = fill_and_stroke(&one_by_one, single);
|
||||
assert_eq!(fill_and_stroke(&batched, in_batch), expected, "art drawn in the batch after {steps:?}");
|
||||
let later = rect(&mut batched);
|
||||
assert_eq!(fill_and_stroke(&batched, later), expected, "art drawn after the batch after {steps:?}");
|
||||
expected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_steps_in_a_batch_change_the_new_art_paints_as_commands_do() {
|
||||
let blue = Paint::solid(Color::from_hex("#2a6fb0").unwrap());
|
||||
assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand"}))]), (blue.clone(), blue));
|
||||
let link = linked("#2a6fb0", "Brand");
|
||||
assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand", "unlink": false}))]), (link.clone(), link));
|
||||
let signal = linked("#00ff00", "Signal");
|
||||
let edit = ("swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
|
||||
assert_eq!(batch_matches_steps(&[edit]), (signal.clone(), signal));
|
||||
let other = linked("#ff8800", "Other");
|
||||
assert_eq!(batch_matches_steps(&[("swatch.merge", json!({"names": ["Other", "Brand"]}))]), (other.clone(), other));
|
||||
let gray = linked("#ababab", "Brand");
|
||||
assert_eq!(batch_matches_steps(&[("swatch.editGroup", json!({"group": "G", "colors": ["#ababab"]}))]), (gray.clone(), gray));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_of_a_swatch_edit_put_back_the_new_art_paints() {
|
||||
let edit = json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"});
|
||||
let delete = json!({"name": "Brand"});
|
||||
for (id, p, fill) in
|
||||
[("swatch.edit", &edit, linked("#00ff00", "Signal")), ("swatch.delete", &delete, Paint::solid(Color::from_hex("#2a6fb0").unwrap()))]
|
||||
{
|
||||
for batch in [false, true] {
|
||||
let mut s = brand_defaults_session();
|
||||
let before = s.paint.clone();
|
||||
if batch {
|
||||
run(&mut s, "command.batch", json!({"commands": [{"command": id, "params": p}]}));
|
||||
} else {
|
||||
run(&mut s, id, p.clone());
|
||||
}
|
||||
assert_eq!(s.paint.fill, fill, "{id}, batch: {batch}");
|
||||
let after = s.paint.clone();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.paint, before, "Undo of {id}, batch: {batch}");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.paint, after, "Redo of {id}, batch: {batch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_edits_reach_the_paints_of_the_new_art_template() {
|
||||
let mut s = brand_defaults_session();
|
||||
s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(linked("#2a6fb0", "Brand"), linked("#2a6fb0", "Brand"), 1.0));
|
||||
run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
|
||||
let template = |s: &Session| -> Vec<Paint> {
|
||||
s.paint
|
||||
.appearance
|
||||
.iter()
|
||||
.flat_map(|a| a.items.iter())
|
||||
.map(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(template(&s), [linked("#00ff00", "Signal"), linked("#00ff00", "Signal")]);
|
||||
run(&mut s, "swatch.delete", json!({"name": "Signal"}));
|
||||
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
|
||||
assert_eq!(template(&s), [green.clone(), green]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_batch_puts_back_the_new_art_paints_a_swatch_step_changed() {
|
||||
let mut s = brand_defaults_session();
|
||||
let before = s.paint.clone();
|
||||
let steps = json!([
|
||||
{"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}},
|
||||
{"command": "no.such.command", "params": {}},
|
||||
]);
|
||||
assert!(s.execute("command.batch", &json!({ "commands": steps })).is_err());
|
||||
assert_eq!(s.paint, before);
|
||||
assert_eq!(doc(&s).swatch("Brand").unwrap().paint.color(), Some(Color::from_hex("#2a6fb0").unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceling_a_swatch_options_preview_keeps_the_new_art_paints() {
|
||||
let mut s = brand_defaults_session();
|
||||
let before = s.paint.clone();
|
||||
s.begin_interaction("Swatch Options").unwrap();
|
||||
s.preview("swatch.edit", &json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"})).unwrap();
|
||||
s.cancel_interaction().unwrap();
|
||||
assert_eq!(s.paint, before);
|
||||
assert!(doc(&s).swatch("Signal").is_none());
|
||||
// A previewed batch that is canceled puts them back too, and one that is accepted keeps them.
|
||||
let steps = json!([{"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}}]);
|
||||
s.begin_interaction("Batch Preview").unwrap();
|
||||
s.preview("command.batch", &json!({ "commands": steps })).unwrap();
|
||||
s.cancel_interaction().unwrap();
|
||||
assert_eq!(s.paint, before);
|
||||
s.begin_interaction("Batch Preview").unwrap();
|
||||
s.preview("command.batch", &json!({ "commands": steps })).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.paint.fill, linked("#00ff00", "Signal"));
|
||||
assert_eq!(s.paint.stroke, linked("#00ff00", "Signal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_several_swatches_and_groups() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -20,9 +20,18 @@ fn close(a: Rect, b: Rect) -> bool {
|
||||
/// A 100 × 100 frame with a 10 × 10 square in its top-left corner and one in its center, made
|
||||
/// into symbol "Panel" with 9-slice guides on, its instance selected at its natural size.
|
||||
fn panel_symbol() -> (Session, NodeId) {
|
||||
panel_symbol_filled(None)
|
||||
}
|
||||
|
||||
/// [`panel_symbol`] with the frame filled with `fill` when there is one.
|
||||
fn panel_symbol_filled(fill: Option<vectorcraft_color::Paint>) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ids = [rect(&mut s, 50.0, 50.0, 100.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0, 10.0)];
|
||||
if let Some(fill) = fill {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
doc.node_mut(ids[0]).unwrap().appearance = vectorcraft_doc::Appearance::basic(fill, vectorcraft_color::Paint::None, 0.0).into();
|
||||
}
|
||||
s.select(|_, sel| sel.set(ids)).unwrap();
|
||||
let r = s.execute("symbol.new", &json!({"name": "Panel", "nineSlice": true})).unwrap();
|
||||
assert_eq!(r["nineSlice"], json!([0.25, 0.25, 0.25, 0.25]));
|
||||
@@ -350,3 +359,39 @@ fn guides_outside_the_art_survive_saving_and_reopening_a_native_file() {
|
||||
assert!(back.symbol_slice_map(symbol, *xf).is_none(), "{bad}: scaled as a whole");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`panel_symbol`] with a gradient on the frame, so that the frame is drawn in a clip group for
|
||||
/// each cell, its instance resized to 200 × 150 and turned 20°.
|
||||
fn turned_gradient_panel() -> (Session, NodeId) {
|
||||
let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
let (mut s, id) = panel_symbol_filled(Some(gradient));
|
||||
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap();
|
||||
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// The bounding box angles of the objects under the art that replaced an instance, and whether
|
||||
/// that art holds a clip group.
|
||||
fn expanded_angles(s: &Session, art: NodeId) -> (Vec<f64>, bool) {
|
||||
let (mut angles, mut clips) = (vec![], false);
|
||||
s.doc().unwrap().doc.node(art).unwrap().walk(&mut |c| {
|
||||
angles.push(c.bbox_angle);
|
||||
clips |= matches!(c.kind, NodeKind::Group { clip: true, .. });
|
||||
});
|
||||
(angles, clips)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_link_turns_the_cells_of_a_sliced_instance_with_its_box() {
|
||||
for reset in [false, true] {
|
||||
let (mut s, _) = turned_gradient_panel();
|
||||
if reset {
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
|
||||
}
|
||||
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
|
||||
let (angles, clips) = expanded_angles(&s, NodeId(r["ids"][0].as_u64().unwrap()));
|
||||
assert!(clips, "the frame is drawn in clip groups");
|
||||
let want = if reset { 0.0 } else { 20.0 };
|
||||
assert!(angles.iter().all(|a| (a - want).abs() < 1e-6), "reset {reset}: {angles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3255,7 +3255,8 @@ fn images_are_written_again_as_the_same_bytes() {
|
||||
|
||||
/// The program of `doc` with LF line ends, and each image's samples (from after `XI` and its line
|
||||
/// end to `%%EndData`, a `%_` before it kept) and each preview's rows (from after `32 17 ` to the
|
||||
/// line end before `%AI26_EndPlacedObjectPreview`) shown as `<n bytes>`.
|
||||
/// line end before `%AI26_EndPlacedObjectPreview` or `%_%AI26_EndPlacedObjectPreview`) shown as
|
||||
/// `<n bytes>`.
|
||||
fn program_shown(doc: &Document) -> String {
|
||||
shown(&export_ai_editing(doc, &opts()).unwrap().program)
|
||||
}
|
||||
@@ -3267,10 +3268,13 @@ fn shown(p: &[u8]) -> String {
|
||||
let mut at = 0;
|
||||
loop {
|
||||
let xi = find(at, b"\r\nXI\r\n").map(|i| (i + 6, b"%%EndData".as_slice()));
|
||||
let pv = find(at, b" 32 17 ").map(|i| (i + 7, b"\r\n%AI26_EndPlacedObjectPreview".as_slice()));
|
||||
let pv = find(at, b" 32 17 ").map(|i| (i + 7, b"%AI26_EndPlacedObjectPreview".as_slice()));
|
||||
let Some((start, end)) = [xi, pv].into_iter().flatten().min_by_key(|(s, _)| *s) else { break };
|
||||
let mut stop = find(start, end).unwrap();
|
||||
if end.starts_with(b"%%") && p.get(stop - 2..stop) == Some(b"%_") {
|
||||
if p.get(stop - 2..stop) == Some(b"%_") {
|
||||
stop -= 2;
|
||||
}
|
||||
if end.starts_with(b"%AI26") && p.get(stop - 2..stop) == Some(b"\r\n") {
|
||||
stop -= 2;
|
||||
}
|
||||
out.extend_from_slice(&p[at..start]);
|
||||
@@ -7354,18 +7358,147 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() {
|
||||
assert_eq!(art[0].name.as_deref(), Some("Photo"));
|
||||
}
|
||||
|
||||
/// A linked image, an image with an opacity mask, and an image with a Warp and another effect are
|
||||
/// written as their drawn art, with no art style.
|
||||
/// [`warped_photo`] linked to `/Users/someone/Job/art/photo.png`, in folder `art` next to the
|
||||
/// document's.
|
||||
fn linked_warped_photo(doc: &mut Document, effects: Vec<vectorcraft_doc::Effect>) -> Node {
|
||||
let mut n = warped_photo(doc, effects);
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job/art/photo.png");
|
||||
link.relative = Some("art/photo.png".into());
|
||||
im.link = Some(link);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// A linked image with one Warp is written with an art style of that Warp, its bent pieces as its
|
||||
/// drawn look and the image itself as a placed file on `%_` lines, with its path, folder and
|
||||
/// preview: it reads back as the linked image, its pixels, placement, name, opacity and link as
|
||||
/// they were, with its Warp live and its settings kept.
|
||||
#[test]
|
||||
fn a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live() {
|
||||
// GUARDS: a linked image with a Warp was written as its drawn pieces alone, and read back
|
||||
// without its Warp or its link.
|
||||
let params = serde_json::json!({"bend": 40.0});
|
||||
let doc = photo_doc(|d| linked_warped_photo(d, vec![fx("warp.arc", params.clone())]));
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let text = program_shown(&doc);
|
||||
assert!(text.contains("\n%_%AI5_BeginPlace\n"), "{text}");
|
||||
assert!(text.contains("\n%_(/Users/someone/Job/art/photo.png)`\n"), "{text}");
|
||||
assert!(text.contains("\n%_(art/)\n"), "{text}");
|
||||
assert!(text.contains("\n%_8 8 32 32 17 <256 bytes>\n"), "{text}");
|
||||
assert!(text.contains("\n%_%AI5_EndPlace\n") && text.contains("\n1 (Anon 1) XW\n"), "{text}");
|
||||
assert!(!text.contains("BeginRaster\n%_"), "no embedded copy: {text}");
|
||||
let back = read_back(&doc);
|
||||
let art = back.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "{:#?}", lines(&back));
|
||||
let (mut want, mut got) = (vec![], vec![]);
|
||||
pictures(&doc, doc.layers[0].children().unwrap(), "", &mut want);
|
||||
pictures(&back, art, "", &mut got);
|
||||
assert_eq!(got, want);
|
||||
let [warp] = art[0].appearance.effects.as_slice() else { panic!("{:?}", art[0].appearance) };
|
||||
assert_eq!(warp.id, "warp.arc");
|
||||
let (w, g) = (vectorcraft_effects::merged_params("warp.arc", ¶ms), vectorcraft_effects::merged_params(&warp.id, &warp.params));
|
||||
for key in ["bend", "horizontal", "vertical"] {
|
||||
assert!((g[key].as_f64().unwrap() - w[key].as_f64().unwrap()).abs() < 6e-3, "{key}: {g} for {w}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A linked image with one Warp larger than a placed file's preview: its copy's preview and its drawn
|
||||
/// look's pieces (placed files of the same image) hold the same smaller preview, so fine detail
|
||||
/// reads back with the Warp live, the link kept and the preview's pixels, as a linked image without
|
||||
/// a Warp does.
|
||||
#[test]
|
||||
fn a_large_linked_image_with_a_warp_reads_back_from_its_preview() {
|
||||
// GUARDS: a linked image with a Warp was written as its drawn pieces alone.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let checks =
|
||||
image::RgbaImage::from_fn(600, 300, |x, y| if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
|
||||
let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job/art/checks.png");
|
||||
link.relative = Some("art/checks.png".into());
|
||||
let xf = Affine::new([0.25, 0.0, 0.0, 0.25, 20.0, 10.0]);
|
||||
let mut n = image_node(&mut doc, ImageBlob::png(png_of(checks.into())), (600, 300), xf, Some(link));
|
||||
n.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into();
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let text = program_shown(&doc);
|
||||
assert!(text.contains("\n%_256 128 1024 32 17 <131072 bytes>\n"), "the copy's preview");
|
||||
assert!(text.contains("\n256 128 1024 32 17 <131072 bytes>\n"), "the look's pieces' previews");
|
||||
let back = read_back(&doc);
|
||||
let art = back.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "{:#?}", lines(&back));
|
||||
let NodeKind::Image(im) = &art[0].kind else { panic!("{:?}", art[0].kind) };
|
||||
assert_eq!((im.width, im.height), (256, 128));
|
||||
assert_eq!(
|
||||
im.link.as_ref().map(|l| (l.path.as_str(), l.relative.as_deref())),
|
||||
Some(("/Users/someone/Job/art/checks.png", Some("art/checks.png")))
|
||||
);
|
||||
assert_eq!(art[0].appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["warp.arc"]);
|
||||
}
|
||||
|
||||
/// A hidden or locked image with one Warp, embedded or linked, keeps its state, its link and its
|
||||
/// live Warp.
|
||||
#[test]
|
||||
fn a_hidden_or_locked_image_with_a_warp_keeps_its_state() {
|
||||
// GUARDS: a linked image with a Warp was written as its drawn pieces alone, and a hidden image's
|
||||
// Warp was compared with a look that drew nothing, so it came in as its drawn pieces.
|
||||
for (what, make) in [("embedded", warped_photo as fn(&mut Document, Vec<vectorcraft_doc::Effect>) -> Node), ("linked", linked_warped_photo)] {
|
||||
for (shown, locked) in [(true, true), (false, false), (false, true)] {
|
||||
let doc = photo_doc(|d| {
|
||||
let mut n = make(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]);
|
||||
(n.visible, n.locked) = (shown, locked);
|
||||
n
|
||||
});
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{what} {shown} {locked}: {:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let art = back.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "{what} {shown} {locked}: {:#?}", lines(&back));
|
||||
assert_eq!(images_of(&back).len(), 1, "{what} {shown} {locked}");
|
||||
assert_eq!(images_of(&back), images_of(&doc), "{what} {shown} {locked}");
|
||||
assert_eq!(art[0].appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["warp.arc"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A linked image with one Warp in an opacity mask's art keeps its live Warp; its copy on `%_` lines
|
||||
/// is embedded, as linked images in masks are, with that warning.
|
||||
#[test]
|
||||
fn a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live() {
|
||||
// GUARDS: the placed file of a styled image written in an opacity mask's art.
|
||||
let doc = photo_doc(|d| {
|
||||
let art = linked_warped_photo(d, vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))]);
|
||||
let mut square =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0));
|
||||
square.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
square
|
||||
});
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::LINKS_EMBEDDED]);
|
||||
let text = program_shown(&doc);
|
||||
assert!(!text.contains("BeginPlace") && text.contains("1 (Anon 1) XW"), "{text}");
|
||||
let back = read_back(&doc);
|
||||
let mask = back.layers[0].children().unwrap()[0].extras().mask.as_ref().map(|m| m.art.clone()).expect("the mask");
|
||||
let NodeKind::Image(im) = &mask.kind else { panic!("{:?}", mask.kind) };
|
||||
assert!(im.link.is_none());
|
||||
assert_eq!(mask.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["warp.arc"]);
|
||||
}
|
||||
|
||||
/// An image with an opacity mask and an image with a Warp and another effect are written as their
|
||||
/// drawn art, with no art style, linked or embedded.
|
||||
#[test]
|
||||
fn other_warped_images_are_written_as_their_drawn_art() {
|
||||
let arc = || fx("warp.arc", serde_json::json!({"bend": 40.0}));
|
||||
let linked = photo_doc(|d| {
|
||||
let mut n = warped_photo(d, vec![arc()]);
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
im.link = Some(vectorcraft_doc::LinkInfo::new("/art/photo.png"));
|
||||
}
|
||||
let linked_masked = photo_doc(|d| {
|
||||
let mut n = linked_warped_photo(d, vec![arc()]);
|
||||
let white =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 10.0, 100.0, 90.0)), Appearance::basic(solid(Color::WHITE), Paint::None, 0.0));
|
||||
n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, false)));
|
||||
n
|
||||
});
|
||||
let linked_stacked = photo_doc(|d| linked_warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))]));
|
||||
let masked = photo_doc(|d| {
|
||||
let mut n = warped_photo(d, vec![arc()]);
|
||||
let white =
|
||||
@@ -7374,7 +7507,7 @@ fn other_warped_images_are_written_as_their_drawn_art() {
|
||||
n
|
||||
});
|
||||
let stacked = photo_doc(|d| warped_photo(d, vec![arc(), fx("stylize.feather", serde_json::json!({"radius": 2.0}))]));
|
||||
for (what, doc) in [("linked", linked), ("masked", masked), ("stacked", stacked)] {
|
||||
for (what, doc) in [("linked masked", linked_masked), ("linked stacked", linked_stacked), ("masked", masked), ("stacked", stacked)] {
|
||||
assert!(!program_shown(&doc).contains("1 (Anon"), "{what}");
|
||||
let back = read_back(&doc);
|
||||
let art = back.layers[0].children().unwrap();
|
||||
@@ -8188,7 +8321,7 @@ impl ArtBrushOptions {
|
||||
|
||||
/// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the
|
||||
/// path, come back as those paths with the brush on their stroke at its weight, not as their drawn
|
||||
/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look.
|
||||
/// look; so does a path with the Art brush that keeps its art's colors.
|
||||
#[test]
|
||||
fn brushed_paths_are_read_back_brushed() {
|
||||
let mut doc = brushes_document();
|
||||
@@ -8208,8 +8341,63 @@ fn brushed_paths_are_read_back_brushed() {
|
||||
assert_eq!(art[1].path_data(), ours[1].path_data());
|
||||
brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
|
||||
assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. }));
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[2];
|
||||
assert!(matches!(n.kind, NodeKind::Path { .. }), "{n:#?}");
|
||||
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Plain ribbon"));
|
||||
}
|
||||
|
||||
/// Art brushes at another width, flipped along or across, scaled proportionately or stretched
|
||||
/// between guides (flipped along too, or at guides the written numbers round) are written with those
|
||||
/// options, and a path with each comes back as the path with the brush on its stroke, except Scale
|
||||
/// Proportionately's, which is written as its brush's art.
|
||||
#[test]
|
||||
fn art_brushes_with_options_are_read_back_with_them() {
|
||||
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind};
|
||||
let thirds = ArtScale::BetweenGuides { start: 1.0 / 3.0, end: 2.0 / 3.0 };
|
||||
let options: Vec<(&str, ArtBrush)> = vec![
|
||||
("Wide", ArtBrush { width: 250.0, ..ArtBrush::default() }),
|
||||
("Along", ArtBrush { flip_along: true, ..ArtBrush::default() }),
|
||||
("Across", ArtBrush { flip_across: true, ..ArtBrush::default() }),
|
||||
("Proportional", ArtBrush { scale: ArtScale::Proportional, width: 50.0, ..ArtBrush::default() }),
|
||||
("Guides", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, ..ArtBrush::default() }),
|
||||
("Guides along", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, flip_along: true, ..ArtBrush::default() }),
|
||||
("Thirds", ArtBrush { scale: thirds, flip_along: true, ..ArtBrush::default() }),
|
||||
];
|
||||
let lib: Vec<Brush> = options
|
||||
.iter()
|
||||
.map(|(name, a)| Brush { name: (*name).into(), kind: BrushKind::Art(ArtBrush { art: brush_art_node(), ..a.clone() }) })
|
||||
.collect();
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
vectorcraft_brush::store(&mut doc, &lib);
|
||||
for (i, b) in lib.iter().enumerate() {
|
||||
let y = 20.0 + 25.0 * i as f64;
|
||||
brushed(&mut doc, &b.name, (20.0, y), (280.0, y), 1.0);
|
||||
}
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let got = vectorcraft_brush::library(&back);
|
||||
assert_eq!(got.len(), lib.len());
|
||||
for (g, w) in got.iter().zip(&lib) {
|
||||
let (BrushKind::Art(g), BrushKind::Art(w)) = (&g.kind, &w.kind) else { panic!("art") };
|
||||
if w.scale == thirds {
|
||||
let ArtScale::BetweenGuides { start, end } = g.scale else { panic!("guides: {g:?}") };
|
||||
assert!((start - 1.0 / 3.0).abs() < 1e-6 && (end - 2.0 / 3.0).abs() < 1e-6, "{g:?}");
|
||||
assert_eq!(ArtBrushOptions::of(&ArtBrush { scale: thirds, ..g.clone() }), ArtBrushOptions::of(w));
|
||||
} else {
|
||||
assert_eq!(ArtBrushOptions::of(g), ArtBrushOptions::of(w));
|
||||
}
|
||||
}
|
||||
for (n, b) in back.layers[0].children().unwrap().iter().zip(&lib) {
|
||||
if b.name == "Proportional" {
|
||||
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}: the brush's art: {n:#?}", b.name);
|
||||
continue;
|
||||
}
|
||||
assert!(matches!(n.kind, NodeKind::Path { .. }), "{}: {n:#?}", b.name);
|
||||
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some(b.name.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A compound path with the Calligraphic brush comes back as that compound path with the brush on its
|
||||
@@ -8513,8 +8701,8 @@ fn brushes_write_the_same_bytes_again() {
|
||||
/// beside a brush that comes back, and each changed from a brush that comes back by the one setting
|
||||
/// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose
|
||||
/// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle
|
||||
/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that
|
||||
/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a
|
||||
/// brushes, Art brushes overlapping, with a width that varies, in another direction or with guides
|
||||
/// that leave no art between them, Art and Scatter brushes in Tints and Shades or Hue Shift, a
|
||||
/// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its
|
||||
/// name from a later one.
|
||||
#[test]
|
||||
@@ -8538,13 +8726,10 @@ fn brushes_the_reader_cant_read_are_left_out() {
|
||||
("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))),
|
||||
("pattern", BrushKind::Pattern(PatternBrush::default())),
|
||||
("bristle", BrushKind::Bristle(Bristle::default())),
|
||||
("flipped along", art_with(&|a| a.flip_along = true)),
|
||||
("flipped across", art_with(&|a| a.flip_across = true)),
|
||||
("overlap", art_with(&|a| a.overlap = true)),
|
||||
("width", art_with(&|a| a.width = 50.0)),
|
||||
("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))),
|
||||
("direction", art_with(&|a| a.direction = Direction::RightToLeft)),
|
||||
("proportional", art_with(&|a| a.scale = ArtScale::Proportional)),
|
||||
("guides with no art between them", art_with(&|a| a.scale = ArtScale::BetweenGuides { start: 0.5, end: 0.5 })),
|
||||
("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)),
|
||||
("art hue shift", art_with(&|a| a.colorization = hue)),
|
||||
(
|
||||
@@ -8754,8 +8939,8 @@ fn an_empty_brushes_panel_comes_back_empty() {
|
||||
}
|
||||
|
||||
/// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its
|
||||
/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle
|
||||
/// brushes are left out with a warning, so the file opens without them.
|
||||
/// brush. The new document's Pattern brushes and its Bristle brushes are left out with a warning,
|
||||
/// so the file opens without them.
|
||||
#[test]
|
||||
fn a_new_documents_brush_on_a_path_writes_the_brushes() {
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
@@ -8765,7 +8950,7 @@ fn a_new_documents_brush_on_a_path_writes_the_brushes() {
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round"));
|
||||
let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"];
|
||||
let left_out = ["Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"];
|
||||
let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect();
|
||||
let lib = vectorcraft_brush::library(&back);
|
||||
assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), want);
|
||||
|
||||
@@ -206,6 +206,9 @@ pub(super) struct Writer<'a> {
|
||||
mask_level: Option<(u32, u32)>,
|
||||
/// The image just written was written as a placed file.
|
||||
placed: bool,
|
||||
/// The image being written is the copy of a styled object on the layers, on `%_` lines: linked,
|
||||
/// it is written as a placed file there ([`Self::styled_object`]).
|
||||
styled_copy: bool,
|
||||
/// The type just written was written as type, not as its outlines.
|
||||
typed: bool,
|
||||
/// The objects bound to variables so far, in the order they are written: each one's id, its XML
|
||||
@@ -299,6 +302,7 @@ impl<'a> Writer<'a> {
|
||||
hidden: 0,
|
||||
mask_level: None,
|
||||
placed: false,
|
||||
styled_copy: false,
|
||||
typed: false,
|
||||
bound: vec![],
|
||||
texts: texts::TextDoc::default(),
|
||||
|
||||
@@ -23,7 +23,7 @@ use super::{Writer, Xy};
|
||||
use crate::ai_ops;
|
||||
use crate::ps;
|
||||
|
||||
pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps";
|
||||
pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't run their art from left to right, that overlap or whose width follows the pen, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps";
|
||||
|
||||
/// Most brushes written (the reader's limit).
|
||||
const MAX_BRUSHES: usize = 4096;
|
||||
@@ -145,22 +145,71 @@ fn scatter_params(s: &Scatter, art: &str) -> Option<String> {
|
||||
Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers)))
|
||||
}
|
||||
|
||||
/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read
|
||||
/// (it reads an Art brush that stretches its art along the path from left to right at 100%,
|
||||
/// unflipped, without overlap, in Tints or keeping its colors).
|
||||
fn art_params(a: &ArtBrush, art: &str) -> Option<String> {
|
||||
let plain = a.direction == Direction::LeftToRight
|
||||
&& matches!(a.scale, ArtScale::Stretch)
|
||||
&& a.width == 100.0
|
||||
&& a.width_mode.is_none()
|
||||
&& !a.flip_along
|
||||
&& !a.flip_across
|
||||
&& !a.overlap;
|
||||
if !plain {
|
||||
/// An Art brush's settings as its parameters' numbers 2 to 15 give them and a brushed stroke's filter
|
||||
/// holds them ([`ART_SETTINGS`]): `None` for options the reader doesn't read (it reads an Art brush
|
||||
/// that runs its art from left to right without overlap, at a fixed width, in Tints or keeping its
|
||||
/// colors). The guides of Stretch Between Guides are the lengths of the art before the first and
|
||||
/// after the second, measured before the art is flipped along the path.
|
||||
fn art_settings(a: &ArtBrush) -> Option<[f64; 14]> {
|
||||
if a.direction != Direction::LeftToRight || a.width_mode.is_some() || a.overlap {
|
||||
return None;
|
||||
}
|
||||
let c = colorization_code(a.colorization)?;
|
||||
let numbers = [1.0, c, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
|
||||
let length = a.art.geometric_bounds().map_or(0.0, |b| b.width());
|
||||
let (proportional, segmented, before, after) = match a.scale {
|
||||
ArtScale::Stretch => (0.0, 0.0, 0.0, 0.0),
|
||||
ArtScale::Proportional => (1.0, 0.0, 0.0, 0.0),
|
||||
ArtScale::BetweenGuides { start, end } if start < end => {
|
||||
let (before, after) = (start * length, (1.0 - end) * length);
|
||||
let (before, after) = if a.flip_along { (after, before) } else { (before, after) };
|
||||
(0.0, 1.0, before, after)
|
||||
}
|
||||
ArtScale::BetweenGuides { .. } => return None,
|
||||
};
|
||||
let scaling = a.width / 100.0;
|
||||
let flip = |on: bool| f64::from(u8::from(on));
|
||||
Some([
|
||||
colorization_code(a.colorization)?,
|
||||
scaling,
|
||||
0.0,
|
||||
1.0,
|
||||
flip(a.flip_along),
|
||||
flip(a.flip_across),
|
||||
proportional,
|
||||
scaling,
|
||||
scaling,
|
||||
0.0,
|
||||
segmented,
|
||||
before,
|
||||
after,
|
||||
1.0,
|
||||
])
|
||||
}
|
||||
|
||||
/// The entries of a brushed stroke's filter that hold an Art brush's settings, in the order of
|
||||
/// [`art_settings`], with how each is written.
|
||||
const ART_SETTINGS: [(&str, &str); 14] = [
|
||||
("Colorization", "Int"),
|
||||
("Scaling", "Real"),
|
||||
("Spacing", "Real"),
|
||||
("Adjustment", "Int"),
|
||||
("Counterclockwise", "Int"),
|
||||
("UpsideDown", "Int"),
|
||||
("Proportional", "Bool"),
|
||||
("MinScaling", "Real"),
|
||||
("MaxScaling", "Real"),
|
||||
("ScaleYDependsOn", "Int"),
|
||||
("Segmented", "Bool"),
|
||||
("StartSegmentLength", "Real"),
|
||||
("EndSegmentLength", "Real"),
|
||||
("FoldAdjustment", "Bool"),
|
||||
];
|
||||
|
||||
/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read
|
||||
/// ([`art_settings`]).
|
||||
fn art_params(a: &ArtBrush, art: &str) -> Option<String> {
|
||||
let settings = art_settings(a)?;
|
||||
let mut numbers = vec![1.0];
|
||||
numbers.extend(settings);
|
||||
Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers)))
|
||||
}
|
||||
|
||||
@@ -458,25 +507,10 @@ impl Writer<'_> {
|
||||
d.push_str("0 /Int (AngleRelativeTo) ,\n");
|
||||
"(Calligraphic Brush) /Title ,\n"
|
||||
}
|
||||
BrushKind::Art(_) => {
|
||||
BrushKind::Art(a) => {
|
||||
d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files
|
||||
for (key, value, kind) in [
|
||||
("StartSegmentLength", "0", "Real"),
|
||||
("EndSegmentLength", "0", "Real"),
|
||||
("Counterclockwise", "0", "Int"),
|
||||
("MinScaling", "1", "Real"),
|
||||
("MaxScaling", "1", "Real"),
|
||||
("Colorization", "1", "Int"),
|
||||
("Spacing", "0", "Real"),
|
||||
("Adjustment", "1", "Int"),
|
||||
("Proportional", "0", "Bool"),
|
||||
("Segmented", "0", "Bool"),
|
||||
("UpsideDown", "0", "Int"),
|
||||
("FoldAdjustment", "1", "Bool"),
|
||||
("ScaleYDependsOn", "0", "Int"),
|
||||
("Scaling", "1", "Real"),
|
||||
] {
|
||||
let _ = writeln!(d, "{value} /{kind} ({key}) ,");
|
||||
for ((key, kind), value) in ART_SETTINGS.iter().zip(art_settings(a)?) {
|
||||
let _ = writeln!(d, "{} /{kind} ({key}) ,", nums6(&[value]));
|
||||
}
|
||||
"(Art Brush) /Title ,\n"
|
||||
}
|
||||
|
||||
@@ -94,7 +94,8 @@ impl Samples {
|
||||
impl Writer<'_> {
|
||||
/// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a
|
||||
/// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on
|
||||
/// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is
|
||||
/// `%_` lines (other than a styled object's copy on the layers, [`Self::styled_object`]) and in
|
||||
/// a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is
|
||||
/// cut to a clipping path, the image is in a clipping group of that path.
|
||||
pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) {
|
||||
let doc = self.doc;
|
||||
@@ -125,7 +126,7 @@ impl Writer<'_> {
|
||||
let fit =
|
||||
Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height));
|
||||
match &im.link {
|
||||
Some(link) if self.hidden == 0 && !self.in_symbol => {
|
||||
Some(link) if (self.hidden == 0 || self.styled_copy) && !self.in_symbol => {
|
||||
// Cut to a region, the placed file is in a clipping group.
|
||||
self.placed = cut.is_none();
|
||||
self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf);
|
||||
|
||||
@@ -42,7 +42,7 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
|
||||
/// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art
|
||||
/// style ([`Writer::styled_object`]), by id (a mask's art root shown, as [`Writer::mask`] writes
|
||||
/// it): paths and compound paths with points and groups, with live effects, linked to a graphic
|
||||
/// style or (paths and compound paths) with a brush on a stroke, and embedded images with one Warp
|
||||
/// style or (paths and compound paths) with a brush on a stroke, and images with one Warp
|
||||
/// ([`warped_image`]), without Opacity & Mask Define
|
||||
/// Knockout Shape (the reader takes an object's transparency
|
||||
/// from its look, which doesn't carry it). A group's members are paths, compound paths or such
|
||||
@@ -100,12 +100,11 @@ fn candidate(n: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` an embedded image without an opacity mask whose appearance is one Warp, with no fills or
|
||||
/// strokes that paint and no effects on them: an image the reader reads with its Warp live
|
||||
/// ([`crate::import::read_art_style`]'s `image_warp`)?
|
||||
/// Is `n` an image, embedded or linked, without an opacity mask whose appearance is one Warp, with
|
||||
/// no fills or strokes that paint and no effects on them: an image the reader reads with its Warp
|
||||
/// live ([`crate::import::read_art_style`]'s `image_warp`)?
|
||||
fn warped_image(n: &Node) -> bool {
|
||||
let NodeKind::Image(im) = &n.kind else { return false };
|
||||
im.link.is_none()
|
||||
matches!(n.kind, NodeKind::Image(_))
|
||||
&& n.extras().mask.is_none()
|
||||
&& matches!(n.appearance.effects.as_slice(), [e] if e.id.starts_with("warp."))
|
||||
&& n.appearance.items.iter().all(|i| i.paint().is_none() && i.effects().is_empty())
|
||||
@@ -725,7 +724,8 @@ impl Writer<'_> {
|
||||
/// glows or feathers, the object's transparency
|
||||
/// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or
|
||||
/// compound path's points painted with nothing, or with its gradient fill when its style has
|
||||
/// one; a group with its members; an image with its pixels), its name and opacity mask; then
|
||||
/// one; a group with its members; an image with its pixels, or on the layers a linked image as
|
||||
/// a placed file with its path, folder and preview), its name and opacity mask; then
|
||||
/// `1 (name) XW`.
|
||||
/// The look's group and the object itself are counted first ([`Self::take_objects`]): without
|
||||
/// room for them, nothing is written, and the object counts neither as written with its graphic
|
||||
@@ -772,6 +772,9 @@ impl Writer<'_> {
|
||||
// The object, on `%_` lines, in its own state (the look's members may have changed it).
|
||||
self.state(orig.locked, !orig.visible);
|
||||
let saved = std::mem::take(&mut self.body);
|
||||
// On the layers, a linked image's copy is a placed file; in an opacity mask's art, it is
|
||||
// embedded, as other linked images there are.
|
||||
let on_layers = self.hidden == 0;
|
||||
self.hidden += 1;
|
||||
let fit = drawn_outline(n, orig).map(|p| p.bounding_box());
|
||||
// A path's or an image's objects were kept for it above; it counts them itself.
|
||||
@@ -781,7 +784,11 @@ impl Writer<'_> {
|
||||
match &orig.kind {
|
||||
NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy),
|
||||
NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy),
|
||||
NodeKind::Image(im) => self.image(&self.doc.shown_image(orig, im), xy),
|
||||
NodeKind::Image(im) => {
|
||||
let was = std::mem::replace(&mut self.styled_copy, on_layers);
|
||||
self.image(&self.doc.shown_image(orig, im), xy);
|
||||
self.styled_copy = was;
|
||||
}
|
||||
NodeKind::Group { children, .. } => {
|
||||
self.line("u");
|
||||
for c in children {
|
||||
|
||||
@@ -36,7 +36,13 @@
|
||||
//! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the
|
||||
//! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the
|
||||
//! rotation follows the path (14), and 1 Tints (1).
|
||||
//! - Art: 1 Tints (2).
|
||||
//! - Art: numbers 2 to 15 are the settings a path's art style holds as entries ([`ArtSettings`]):
|
||||
//! the colorization (2: 0 None, 1 Tints; other codes color the art otherwise), the width as a factor
|
||||
//! (3), `Spacing` (4), `Adjustment` (5), flipped along (6) and across (7), Scale Proportionately
|
||||
//! (8), the lowest and highest width when the width follows the pen (9, 10, not drawn otherwise),
|
||||
//! how the width varies (11, 0 Fixed), Stretch Between Guides (12) and the lengths of the art
|
||||
//! before its first guide and after its second (13, 14, in the art's units, measured before
|
||||
//! the art is flipped along the path), and `FoldAdjustment` (15).
|
||||
//! - Pattern: its scale as a factor (3).
|
||||
//! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle
|
||||
//! thickness, density, paint opacity and bristle length as fractions and the size in
|
||||
@@ -45,14 +51,15 @@
|
||||
//! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame
|
||||
//! at the art's bounds isn't kept ([`without_frame`]); a wider one is.
|
||||
//!
|
||||
//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles
|
||||
//! What isn't read keeps its default: an Art brush's settings that VectorCraft doesn't draw as the
|
||||
//! file does ([`art_options`]) apart from its colorization, the pattern brush's tiles
|
||||
//! (which slot is which tile isn't known) and the colorization of the other brushes, which is left
|
||||
//! at None so the art keeps its own colors.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use kurbo::Affine;
|
||||
use vectorcraft_brush::{ArtBrush, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
|
||||
use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
|
||||
|
||||
@@ -118,13 +125,13 @@ pub(super) enum DraftKind {
|
||||
brush: Scatter,
|
||||
art: String,
|
||||
},
|
||||
/// The art is the section named `art`. `plain`: the brush stretches its art along the path,
|
||||
/// unflipped, at 100% in Tints (see [`art_is_plain`]), so [`Self::Art`]'s `brush` draws as the
|
||||
/// file's brush does.
|
||||
/// The art is the section named `art`. `settings`: the brush's settings when [`art_options`]
|
||||
/// reads them, so [`Self::Art`]'s `brush` draws as the file's brush does (its guides set from
|
||||
/// its art's length by [`finish_brushes`]).
|
||||
Art {
|
||||
brush: ArtBrush,
|
||||
art: String,
|
||||
plain: bool,
|
||||
settings: Option<ArtSettings>,
|
||||
},
|
||||
/// The five art sections the brush names, in its order (which is which tile isn't known), and
|
||||
/// its scale (%).
|
||||
@@ -226,12 +233,17 @@ pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3
|
||||
Calligraphic { angle, roundness, size, variation, modes: Some(modes) }
|
||||
}
|
||||
|
||||
/// An Art brush's settings: its object's numbers 2 to 15, which a path's art style holds as the
|
||||
/// entries `Colorization`, `Scaling`, `Spacing`, `Adjustment`, `Counterclockwise`, `UpsideDown`,
|
||||
/// `Proportional`, `MinScaling`, `MaxScaling`, `ScaleYDependsOn`, `Segmented`,
|
||||
/// `StartSegmentLength`, `EndSegmentLength` and `FoldAdjustment`, in that order.
|
||||
pub(super) type ArtSettings = [f64; 14];
|
||||
|
||||
/// The settings a path's art style gives its stroke's brush.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum PathBrush {
|
||||
Calligraphic(Calligraphic),
|
||||
/// An Art brush whose art stretches along the path, at the brush's own width, in Tints.
|
||||
Art,
|
||||
Art(ArtSettings),
|
||||
}
|
||||
|
||||
/// Does an Art brush draw `art` as the file does? It bends paths in solid colors: other objects
|
||||
@@ -250,8 +262,9 @@ pub(super) fn art_bends(art: &Node) -> bool {
|
||||
bends
|
||||
}
|
||||
|
||||
/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does.
|
||||
pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> HashSet<String> {
|
||||
/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does, with the
|
||||
/// length of each (its width, the art running left to right).
|
||||
pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> HashMap<String, f64> {
|
||||
let names: HashSet<&String> = drafts
|
||||
.iter()
|
||||
.filter_map(|d| match &d.kind {
|
||||
@@ -259,13 +272,15 @@ pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) ->
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
names.into_iter().filter(|n| art.get(*n).is_some_and(art_bends)).cloned().collect()
|
||||
names.into_iter().filter_map(|n| art.get(n).filter(|a| art_bends(a)).and_then(|a| a.geometric_bounds()).map(|b| (n.clone(), b.width()))).collect()
|
||||
}
|
||||
|
||||
/// Does the brush `draft` draw as `settings`, the settings a path's art style gives it? A path whose
|
||||
/// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and
|
||||
/// has no settings of its own. `bending`: [`bending_art`].
|
||||
pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashSet<String>) -> bool {
|
||||
/// has no settings of its own. An Art brush's settings that don't draw ([`drawn`]) may differ, and
|
||||
/// the path's own settings must be ones the brush would open with ([`art_options`], [`opens_live`]).
|
||||
/// `bending`: [`bending_art`].
|
||||
pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashMap<String, f64>) -> bool {
|
||||
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
|
||||
match (&draft.kind, settings) {
|
||||
(DraftKind::Calligraphic(d), PathBrush::Calligraphic(c)) => {
|
||||
@@ -275,16 +290,90 @@ pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashS
|
||||
&& d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b))
|
||||
&& d.modes() == c.modes()
|
||||
}
|
||||
(DraftKind::Art { plain: true, art: name, .. }, PathBrush::Art) => bending.contains(name),
|
||||
(DraftKind::Art { settings: Some(d), art: name, .. }, PathBrush::Art(s)) => {
|
||||
let (dd, ds) = (drawn(d), drawn(s));
|
||||
art_options(s).is_some()
|
||||
&& bending.get(name).is_some_and(|length| {
|
||||
// The guides match as lengths and as fractions of the art's length, so they differ
|
||||
// neither on short art nor near the ends of long art.
|
||||
dd.iter().zip(ds).all(|(a, b)| close(*a, b))
|
||||
&& [11, 12].iter().all(|&i| close(dd[i] / length, ds[i] / length))
|
||||
&& opens_live(d, *length)
|
||||
&& opens_live(s, *length)
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Are an Art brush's last 15 numbers those of a brush that stretches its art along the path,
|
||||
/// unflipped, at 100% in Tints? The numbers aren't read otherwise: any other value keeps its paths'
|
||||
/// drawn look.
|
||||
fn art_is_plain(numbers: &[f64; 15]) -> bool {
|
||||
*numbers == [1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
|
||||
/// Art brush settings `s` with those that don't draw set to 0: the lowest and highest width while the
|
||||
/// width is fixed, and the guides' lengths without Stretch Between Guides.
|
||||
fn drawn(s: &ArtSettings) -> ArtSettings {
|
||||
let mut out = *s;
|
||||
if s[9] == 0.0 {
|
||||
(out[7], out[8]) = (0.0, 0.0);
|
||||
}
|
||||
if s[10] == 0.0 {
|
||||
(out[11], out[12]) = (0.0, 0.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What an Art brush's settings give VectorCraft's Art brush, its guides apart ([`guides`]): `None`
|
||||
/// when VectorCraft doesn't draw them as the file does: a colorization other than None or Tints, a
|
||||
/// width outside [`ArtBrush::WIDTH_RANGE`] or one that varies, both Scale Proportionately and Stretch
|
||||
/// Between Guides, a flip or scale that isn't 0 or 1, or a `Spacing`, `Adjustment` or
|
||||
/// `FoldAdjustment` other than 0, 1 and 1 (what other values change isn't known).
|
||||
fn art_options(s: &ArtSettings) -> Option<ArtBrush> {
|
||||
let [colorization, scaling, spacing, adjustment, along, across, proportional, _, _, varies, segmented, _, _, fold] = *s;
|
||||
let switch = |v: f64| match v {
|
||||
0.0 => Some(false),
|
||||
1.0 => Some(true),
|
||||
_ => None,
|
||||
};
|
||||
let colorization = match colorization {
|
||||
0.0 => Colorization::None,
|
||||
1.0 => Colorization::Tints,
|
||||
_ => return None,
|
||||
};
|
||||
let width = scaling * 100.0;
|
||||
let (low, high) = ArtBrush::WIDTH_RANGE;
|
||||
if !(low..=high).contains(&width) || varies != 0.0 || spacing != 0.0 || adjustment != 1.0 || fold != 1.0 {
|
||||
return None;
|
||||
}
|
||||
let scale = match (switch(proportional)?, switch(segmented)?) {
|
||||
(false, false) => ArtScale::Stretch,
|
||||
(true, false) => ArtScale::Proportional,
|
||||
(false, true) => ArtScale::BetweenGuides { start: 0.0, end: 1.0 },
|
||||
(true, true) => return None,
|
||||
};
|
||||
Some(ArtBrush { scale, width, flip_along: switch(along)?, flip_across: switch(across)?, colorization, ..ArtBrush::default() })
|
||||
}
|
||||
|
||||
/// The guides of Art brush settings `s` (with Stretch Between Guides) for art of length `length`, as
|
||||
/// [`ArtScale::BetweenGuides`] fractions of the art as it runs along the path: `None` when they leave
|
||||
/// no art between them. The settings measure the art before it is flipped along the path.
|
||||
fn guides(s: &ArtSettings, length: f64) -> Option<(f64, f64)> {
|
||||
let (before, after) = (s[11], s[12]);
|
||||
if !(before >= 0.0 && after >= 0.0 && before + after < length) {
|
||||
return None;
|
||||
}
|
||||
let (before, after) = if s[4] == 1.0 { (after, before) } else { (before, after) };
|
||||
Some((before / length, 1.0 - after / length))
|
||||
}
|
||||
|
||||
/// Do Art brush settings `s` draw art of length `length`: no Stretch Between Guides, or guides with
|
||||
/// art between them?
|
||||
fn guides_fit(s: &ArtSettings, length: f64) -> bool {
|
||||
s[10] == 0.0 || guides(s, length).is_some()
|
||||
}
|
||||
|
||||
/// Does a path with Art brush settings `s`, its art of length `length`, open with the brush on its
|
||||
/// stroke? Its guides must fit ([`guides_fit`]). Scale Proportionately keeps the drawn look:
|
||||
/// VectorCraft's Art brush sizes proportional art by the path's length and not by the stroke's
|
||||
/// weight.
|
||||
fn opens_live(s: &ArtSettings, length: f64) -> bool {
|
||||
s[6] == 0.0 && guides_fit(s, length)
|
||||
}
|
||||
|
||||
/// The brush named `name` of kind `kind` from its parameters `params`.
|
||||
@@ -342,15 +431,22 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
|
||||
Tool::Art => {
|
||||
// The older form's 8 numbers aren't known: its brush keeps the defaults and its
|
||||
// paths their drawn look. The count tells the form, not the first number.
|
||||
// Any other count isn't a form this reads.
|
||||
let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) {
|
||||
(if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15))
|
||||
// Any other count isn't a form this reads. Settings that aren't read keep the
|
||||
// defaults but the colorization, and their paths their drawn look.
|
||||
let (brush, settings) = if let Ok(&[first, ref settings @ ..]) = <&[f64; 15]>::try_from(n.as_slice()) {
|
||||
match art_options(settings).filter(|_| first == 1.0) {
|
||||
Some(brush) => (brush, Some(*settings)),
|
||||
None => {
|
||||
let colorization = if settings[0] == 1.0 { Colorization::Tints } else { Colorization::None };
|
||||
(ArtBrush { colorization, ..ArtBrush::default() }, None)
|
||||
}
|
||||
}
|
||||
} else if n.len() == 8 {
|
||||
(ArtBrush::default().colorization, false)
|
||||
(ArtBrush::default(), None)
|
||||
} else {
|
||||
return Err(BrushError::Values);
|
||||
};
|
||||
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain }
|
||||
DraftKind::Art { brush, art: art()?, settings }
|
||||
}
|
||||
_ => {
|
||||
let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) };
|
||||
@@ -453,10 +549,15 @@ pub(super) fn finish_brushes_within(
|
||||
_ if !kept => None,
|
||||
DraftKind::Calligraphic(c) => Some(BrushKind::Calligraphic(c)),
|
||||
DraftKind::Scatter { brush, art: name } => copy(&name).map(|a| BrushKind::Scatter(Scatter { art: a, ..brush })),
|
||||
DraftKind::Art { brush, art: name, .. } => copy(&name).map(|mut a| {
|
||||
DraftKind::Art { mut brush, art: name, settings } => copy(&name).map(|mut a| {
|
||||
// The side of the section with y up draws on the right of the path, and a brush
|
||||
// draws art's y (down here) on the left of the path.
|
||||
a.transform(Affine::scale_non_uniform(1.0, -1.0), false);
|
||||
if let ArtScale::BetweenGuides { start, end } = &mut brush.scale {
|
||||
// Guides that leave no art between them stretch the whole art.
|
||||
let length = a.geometric_bounds().map_or(0.0, |b| b.width());
|
||||
(*start, *end) = settings.and_then(|s| guides(&s, length)).unwrap_or((0.0, 1.0));
|
||||
}
|
||||
BrushKind::Art(ArtBrush { art: a, ..brush })
|
||||
}),
|
||||
DraftKind::Pattern { .. } => None,
|
||||
@@ -586,8 +687,8 @@ pub(crate) struct BrushRead {
|
||||
pub brush: Brush,
|
||||
/// A path whose art style names the brush, at the brush's own settings, opens with the brush on
|
||||
/// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file
|
||||
/// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the
|
||||
/// path, unflipped, at 100% in Tints.
|
||||
/// does ([`art_bends`]) and whose settings VectorCraft draws as the file does ([`art_options`],
|
||||
/// with guides that leave art between them and without Scale Proportionately: [`opens_live`]).
|
||||
pub live: bool,
|
||||
}
|
||||
|
||||
@@ -604,7 +705,9 @@ pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Opt
|
||||
let live = kept(&drafts, §ions).first().copied().unwrap_or(false)
|
||||
&& match &drafts[0].kind {
|
||||
DraftKind::Calligraphic(_) => true,
|
||||
DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, §ions).contains(section),
|
||||
DraftKind::Art { settings: Some(s), art: section, .. } => {
|
||||
bending_art(&drafts, §ions).get(section).is_some_and(|length| opens_live(s, *length))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let [draft] = drafts;
|
||||
@@ -643,8 +746,8 @@ pub(crate) struct BrushesRead {
|
||||
/// The brush a stroke that names a brush is drawn with, by name: the first one kept
|
||||
/// ([`kept`]), as an index in [`Self::drafts`].
|
||||
by_name: HashMap<String, usize>,
|
||||
/// The art an Art brush draws as the file does ([`bending_art`]).
|
||||
bending: HashSet<String>,
|
||||
/// The art an Art brush draws as the file does, with its length ([`bending_art`]).
|
||||
bending: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
impl BrushesRead {
|
||||
@@ -681,7 +784,7 @@ impl BrushesRead {
|
||||
st.items.iter().all(|item| match item {
|
||||
StyleItem::Stroke { brush: Some((name, settings)), .. } => {
|
||||
let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false };
|
||||
draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty())
|
||||
draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art(_)) && !st.effects.is_empty())
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! next layers: a clipping group in each layer, read by [`masks`].
|
||||
//! - Ruler guides: a straight `(N) *` guide path, directly on a layer, that runs across the whole
|
||||
//! canvas (16,383 points around the template box's centre) or from edge to edge of one artboard
|
||||
//! is a ruler guide on that layer ([`Document::guides`]); other guide paths stay paths.
|
||||
//! is a ruler guide on that layer ([`Document::guides`]), shown or hidden, with the path's name,
|
||||
//! lock and visibility; other guide paths stay paths.
|
||||
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
|
||||
//! with `W` clips); a group's transparency and name follow its end. A wraparound group `*w` …
|
||||
//! `*W` (version 7 specification §15.4.2) is a group whose objects other than its type wrap
|
||||
@@ -437,8 +438,8 @@ struct BrushesKept {
|
||||
read: (usize, usize),
|
||||
/// Which of [`Reader::brush_drafts`] are kept as brushes.
|
||||
kept: Vec<bool>,
|
||||
/// The art an Art brush draws as the file does.
|
||||
bending: HashSet<String>,
|
||||
/// The art an Art brush draws as the file does, with its length.
|
||||
bending: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
|
||||
@@ -234,11 +234,10 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide };
|
||||
let mut node = self.new_node(kind, &gs);
|
||||
// A ruler guide is written as a straight `(N) *` guide path on its layer.
|
||||
// A ruler guide is written as a straight `(N) *` guide path on its layer, shown or hidden.
|
||||
if guide
|
||||
&& op == "N"
|
||||
&& !hidden
|
||||
&& !gs.hidden
|
||||
&& !clip
|
||||
&& self.pattern.is_none()
|
||||
&& matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. }))
|
||||
|
||||
@@ -1181,7 +1181,7 @@ mod tests {
|
||||
assert!(nodes > 0);
|
||||
let draft = |i: usize| brush::BrushDraft {
|
||||
name: format!("Brush {i}"),
|
||||
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), plain: false },
|
||||
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), settings: None },
|
||||
};
|
||||
let drafts: Vec<_> = (0..5).map(draft).collect();
|
||||
let (kept, left_out) = brush::finish_brushes_within(drafts.clone(), &reader.brush_art, None, 3 * nodes);
|
||||
|
||||
@@ -1445,7 +1445,8 @@ fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect])
|
||||
}
|
||||
|
||||
/// Makes the children of the layers and sublayers of `nodes` that `pick` makes a guide of into
|
||||
/// ruler guide nodes with the same ids, moved to the top of their layer in their order.
|
||||
/// ruler guide nodes with the same ids, names, locks and visibility, moved to the top of their layer
|
||||
/// in their order.
|
||||
fn take_ruler_guides(nodes: &mut [Arc<Node>], pick: &dyn Fn(NodeId) -> Option<Guide>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
@@ -1458,7 +1459,11 @@ fn take_ruler_guides(nodes: &mut [Arc<Node>], pick: &dyn Fn(NodeId) -> Option<Gu
|
||||
let mut guides = vec![];
|
||||
kids.retain(|k| match pick(k.id) {
|
||||
Some(g) => {
|
||||
guides.push(Arc::new(Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard })));
|
||||
let mut n = Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard });
|
||||
n.name = k.name.clone();
|
||||
n.locked = k.locked;
|
||||
n.visible = k.visible;
|
||||
guides.push(Arc::new(n));
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
|
||||
@@ -992,8 +992,8 @@ struct StylePaints<'a> {
|
||||
brushes: &'a [super::super::brush::BrushDraft],
|
||||
/// Which of [`Self::brushes`] are kept as brushes ([`super::super::brush::kept`]).
|
||||
kept: &'a [bool],
|
||||
/// The art an Art brush draws as the file does ([`super::super::brush::bending_art`]).
|
||||
bending: &'a HashSet<String>,
|
||||
/// The art an Art brush draws as the file does, with its length ([`super::super::brush::bending_art`]).
|
||||
bending: &'a HashMap<String, f64>,
|
||||
space: Affine,
|
||||
}
|
||||
|
||||
@@ -1141,7 +1141,8 @@ fn image_warps(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
|
||||
/// Does image `object` with `appearance` (its style's Warp) draw as `look`, its drawn look, compared
|
||||
/// over all that the warp draws ([`crate::import::ai::envelope::warped_alike`]) on `layer`, a layer
|
||||
/// id no other node has? Both are drawn shown, opaque and in the Normal mode. Unless the look is on
|
||||
/// id no other node has? Both are drawn shown, opaque and in the Normal mode; a hidden look is drawn
|
||||
/// with every object in it shown, as the hidden object's look holds them. Unless the look is on
|
||||
/// `%_` lines itself (`hidden`), the copies in it that are on `%_` lines (`hidden_ids`) are left out.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn image_warp_alike(
|
||||
@@ -1161,12 +1162,24 @@ fn image_warp_alike(
|
||||
if !hidden {
|
||||
strip(&mut plain, hidden_ids);
|
||||
}
|
||||
if !plain.visible {
|
||||
show_members(&mut plain);
|
||||
}
|
||||
for n in [&mut live, &mut plain] {
|
||||
(n.visible, n.opacity, n.blend) = (true, 1.0, BlendMode::Normal);
|
||||
}
|
||||
crate::import::ai::envelope::warped_alike(&live, &plain, layer, shared, page, spent)
|
||||
}
|
||||
|
||||
/// Show every object in container `n`, at any depth.
|
||||
fn show_members(n: &mut Node) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
let c = Arc::make_mut(c);
|
||||
c.visible = true;
|
||||
show_members(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names.
|
||||
const PATTERN_KEY: &str = "\u{1}pattern ";
|
||||
|
||||
@@ -1257,7 +1270,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
|
||||
if !super::super::brush::draws_as(draft, settings, paints.bending) {
|
||||
return None;
|
||||
}
|
||||
let art = matches!(settings, super::super::brush::PathBrush::Art);
|
||||
let art = matches!(settings, super::super::brush::PathBrush::Art(_));
|
||||
let on_a_path = matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
|
||||
if !on_a_path && (art || !slot) {
|
||||
return None;
|
||||
@@ -1557,7 +1570,7 @@ mod tests {
|
||||
let st = &styles["Inked"];
|
||||
let brushes = [parse_tool_object(Tool::Calligraphic, "N", "1 4 8 30 50 -150 210 2 5 6 0").unwrap()];
|
||||
let (gradients, patterns, names, v7_patterns, bending) =
|
||||
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new());
|
||||
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new());
|
||||
let paints = StylePaints {
|
||||
gradients: &gradients,
|
||||
patterns: &patterns,
|
||||
@@ -1603,7 +1616,7 @@ mod tests {
|
||||
// GUARDS: an image with a Warp in its art style came in as its drawn look.
|
||||
let warp = ("Adobe Deform", "1 /Int (DeformStyle) ,\n0.4 /Real (DeformValue) ,\n"); // brand-ok: the filter's name in the files
|
||||
let (gradients, patterns, names, v7_patterns, bending) =
|
||||
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new());
|
||||
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new());
|
||||
let paints = StylePaints {
|
||||
gradients: &gradients,
|
||||
patterns: &patterns,
|
||||
|
||||
@@ -1500,11 +1500,30 @@ const ART_ENTRIES: [&str; 18] = [
|
||||
"Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files
|
||||
];
|
||||
|
||||
/// An Art brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a color, names
|
||||
/// no Art brush, holds an entry that isn't read, or its settings aren't those of art that stretches
|
||||
/// along the path, unflipped, at the brush's own width (`Scaling` 1), in Tints (`Colorization` 1),
|
||||
/// with `FoldAdjustment` 1, which is [`vectorcraft_brush::ArtBrush`]'s `overlap: false`: its drawn
|
||||
/// look stays.
|
||||
/// The entries of an Art brush's filter that hold its settings, in the order of
|
||||
/// [`super::brush::ArtSettings`], with how each is written and its value when the filter leaves it
|
||||
/// out (`None`: the filter must hold it).
|
||||
const ART_SETTINGS: [(&str, Kind, Option<f64>); 14] = [
|
||||
("Colorization", Kind::Int, None),
|
||||
("Scaling", Kind::Real, None),
|
||||
("Spacing", Kind::Real, Some(0.0)),
|
||||
("Adjustment", Kind::Int, Some(1.0)),
|
||||
("Counterclockwise", Kind::Int, Some(0.0)),
|
||||
("UpsideDown", Kind::Int, Some(0.0)),
|
||||
("Proportional", Kind::Bool, Some(0.0)),
|
||||
("MinScaling", Kind::Real, Some(1.0)),
|
||||
("MaxScaling", Kind::Real, Some(1.0)),
|
||||
("ScaleYDependsOn", Kind::Int, Some(0.0)),
|
||||
("Segmented", Kind::Bool, Some(0.0)),
|
||||
("StartSegmentLength", Kind::Real, Some(0.0)),
|
||||
("EndSegmentLength", Kind::Real, Some(0.0)),
|
||||
("FoldAdjustment", Kind::Bool, Some(1.0)),
|
||||
];
|
||||
|
||||
/// An Art brush's filter `f` as the stroke it paints, with its settings: `None` when it isn't a
|
||||
/// stroke in a color, names no Art brush, holds an entry that isn't read or a setting that isn't a
|
||||
/// number of its kind, or tints its art with a swatch's color: its drawn look stays. Whether the
|
||||
/// brush draws as these settings is [`super::brush::draws_as`]'s to say.
|
||||
fn art_stroke(f: &Filter) -> Option<StyleItem> {
|
||||
if !f.paints.0
|
||||
|| f.gradient
|
||||
@@ -1519,28 +1538,13 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
|
||||
if plugin != ART_BRUSH {
|
||||
return None;
|
||||
}
|
||||
let off = |key: &str, kind: Kind| matches!(entry(f, key, kind), Ok(None | Some(0.0)));
|
||||
let plain = off("Proportional", Kind::Bool)
|
||||
&& off("Segmented", Kind::Bool)
|
||||
&& off("UpsideDown", Kind::Int)
|
||||
&& off("Counterclockwise", Kind::Int)
|
||||
&& off("ScaleYDependsOn", Kind::Int)
|
||||
&& off("Spacing", Kind::Real)
|
||||
&& off("StartSegmentLength", Kind::Real)
|
||||
&& off("EndSegmentLength", Kind::Real);
|
||||
if !plain || entry(f, "Adjustment", Kind::Int).ok()?.unwrap_or(1.0) != 1.0 || entry(f, "FoldAdjustment", Kind::Bool).ok()?.unwrap_or(1.0) != 1.0 {
|
||||
return None;
|
||||
}
|
||||
let one = |key: &str| entry(f, key, Kind::Real).is_ok_and(|v| v.unwrap_or(1.0) == 1.0);
|
||||
if entry(f, "Scaling", Kind::Real).ok()?? != 1.0 || !one("MinScaling") || !one("MaxScaling") {
|
||||
return None;
|
||||
}
|
||||
if entry(f, "Colorization", Kind::Int).ok()?? != 1.0 {
|
||||
return None;
|
||||
let mut settings = [0.0; 14];
|
||||
for (slot, (key, kind, missing)) in settings.iter_mut().zip(ART_SETTINGS) {
|
||||
*slot = entry(f, key, kind).ok()?.or(missing)?;
|
||||
}
|
||||
let paint = f.stroke.clone()?;
|
||||
// The tinted pieces are process colors, so a swatch's ink would be lost.
|
||||
if matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
|
||||
if settings[0] == 1.0 && matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
|
||||
return None;
|
||||
}
|
||||
Some(StyleItem::Stroke {
|
||||
@@ -1552,7 +1556,7 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
|
||||
dash: None,
|
||||
profile: None,
|
||||
align: StrokeAlign::Center,
|
||||
brush: Some((name.clone(), PathBrush::Art)),
|
||||
brush: Some((name.clone(), PathBrush::Art(settings))),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8832,7 +8832,8 @@ fn other_guide_paths_stay_guide_paths() {
|
||||
guide_line(0.0, 30.0, 200.0, 30.0, "S"),
|
||||
guide_line(-7885.0, -7796.0, 8498.0, 8587.0, "N"),
|
||||
format!("u\n{}U\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
|
||||
format!("1 Xw\n{}0 Xw\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
|
||||
format!("1 Xw\n{}0 Xw\n", guide_line(20.0, 40.0, 80.0, 40.0, "N")),
|
||||
format!("1 A\n{}0 A\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "S")),
|
||||
"-7885 40 m\n306 40 L\n8498 40 L\n(N) *\n".to_string(),
|
||||
];
|
||||
for body in cases {
|
||||
@@ -8842,6 +8843,35 @@ fn other_guide_paths_stay_guide_paths() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hidden (`1 Xw`), locked (`1 A`) or named ruler guide path is a ruler guide that keeps its own
|
||||
/// visibility, lock and name, across the canvas or an artboard, on a shown layer.
|
||||
#[test]
|
||||
fn hidden_locked_and_named_guide_paths_are_ruler_guides() {
|
||||
let body = format!(
|
||||
"1 Xw\n{}0 Xw\n1 A\n{}0 A\n{}%_/ArtDictionary :\n%_(Mark) /String (AIArtName) ,\n%_;\n%_\n1 Xw\n{}0 Xw\n",
|
||||
guide_line(-7885.0, 40.0, 8498.0, 40.0, "N"),
|
||||
guide_line(25.0, 8587.0, 25.0, -7796.0, "N"),
|
||||
guide_line(-7885.0, 90.0, 8498.0, 90.0, "N"),
|
||||
guide_line(0.0, 30.0, 200.0, 30.0, "N"),
|
||||
);
|
||||
let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
|
||||
let top = &doc.layers[0];
|
||||
assert!(top.visible);
|
||||
let kids = top.children().unwrap();
|
||||
assert_eq!(kids.len(), 4);
|
||||
assert!(kids.iter().all(|c| c.is_ruler_guide()), "no guide path is left");
|
||||
let got: Vec<_> = doc.ruler_guides().into_iter().map(|g| (g.vertical, g.pos, g.artboard, g.layer, g.hidden, g.locked, g.name)).collect();
|
||||
let want = [
|
||||
(false, 60.0, None, Some(top.id), true, false, None),
|
||||
(true, 25.0, None, Some(top.id), false, true, None),
|
||||
(false, 10.0, None, Some(top.id), false, false, Some("Mark".to_string())),
|
||||
(false, 70.0, Some(1), Some(top.id), true, false, None),
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
// The nodes themselves carry the state, so the Layers rows show it.
|
||||
assert_eq!(kids.iter().map(|k| (k.visible, k.locked)).collect::<Vec<_>>(), [(false, false), (true, true), (true, false), (false, false)]);
|
||||
}
|
||||
|
||||
/// The layer whose `Lb` has `ops` as its options (the first one in the file).
|
||||
fn layer_with(ops: &str) -> Node {
|
||||
let doc = read_ok(&format!("%AI5_BeginLayer\n{ops} Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n")).doc;
|
||||
@@ -9767,7 +9797,7 @@ fn a_pattern_brushs_five_slots_are_kept() {
|
||||
fn an_older_art_brush_comes_in_with_its_art() {
|
||||
const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0";
|
||||
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap();
|
||||
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}");
|
||||
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, settings: None, .. } if art == "Ribbon art"), "{d:?}");
|
||||
let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") };
|
||||
assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization");
|
||||
// A count of numbers neither form has isn't read.
|
||||
@@ -9777,8 +9807,8 @@ fn an_older_art_brush_comes_in_with_its_art() {
|
||||
assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values));
|
||||
// 8 numbers are the older form whatever the first number says: read with the defaults.
|
||||
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap();
|
||||
let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") };
|
||||
assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization));
|
||||
let brush::DraftKind::Art { ref brush, ref art, settings } = d.kind else { panic!("art") };
|
||||
assert_eq!((art.as_str(), settings, brush.colorization), ("Ribbon art", None, vectorcraft_brush::ArtBrush::default().colorization));
|
||||
let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat();
|
||||
let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer("")));
|
||||
let lib = vectorcraft_brush::library(&r.doc);
|
||||
@@ -10441,9 +10471,109 @@ fn an_art_brush_bends_only_paths_in_solid_fills() {
|
||||
assert!(wrong.is_empty(), "{wrong:?}");
|
||||
}
|
||||
|
||||
/// The entries of an Art brush's filter that hold its settings, in the order of its object's numbers 2
|
||||
/// to 15, with how each is written.
|
||||
const ART_KEYS: [(&str, &str); 14] = [
|
||||
("Colorization", "Int"),
|
||||
("Scaling", "Real"),
|
||||
("Spacing", "Real"),
|
||||
("Adjustment", "Int"),
|
||||
("Counterclockwise", "Int"),
|
||||
("UpsideDown", "Int"),
|
||||
("Proportional", "Bool"),
|
||||
("MinScaling", "Real"),
|
||||
("MaxScaling", "Real"),
|
||||
("ScaleYDependsOn", "Int"),
|
||||
("Segmented", "Bool"),
|
||||
("StartSegmentLength", "Real"),
|
||||
("EndSegmentLength", "Real"),
|
||||
("FoldAdjustment", "Bool"),
|
||||
];
|
||||
|
||||
/// The Art brush `Ribbon` (its art 40 long: [`lopsided_art`]) whose settings, numbers 2 to 15 of its
|
||||
/// object, are those of [`RIBBON`] with `changes` (index in the 14, value), and the style `Inked` that
|
||||
/// gives a path the same settings.
|
||||
fn ribbon_with(changes: &[(usize, &str)]) -> (String, String) {
|
||||
let mut settings = ["1", "1", "0", "1", "0", "0", "0", "1", "1", "0", "0", "0", "0", "1"];
|
||||
for (i, v) in changes {
|
||||
settings[*i] = v;
|
||||
}
|
||||
let brush = format!("2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 {}", settings.join(" "));
|
||||
let entries: Vec<(&str, &str, &str)> = ART_KEYS.iter().zip(settings).map(|((key, kind), value)| (*key, value, *kind)).collect();
|
||||
(format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", &brush)), art_style("Ribbon", 1.0, &entries, ""))
|
||||
}
|
||||
|
||||
/// A path with an Art brush whose settings are its own comes in brushed, the brush with those
|
||||
/// settings: None, another width (the lowest and highest widths aren't drawn while the width is
|
||||
/// fixed), flipped along or across, and Stretch Between Guides, whose guides come in as fractions of
|
||||
/// the art as it runs along the path (measured before it is flipped along it, so a flip along swaps
|
||||
/// them). Lengths without Stretch Between Guides aren't guides. A brush that scales its art
|
||||
/// proportionately comes into the panel with that option, and its path keeps its drawn look.
|
||||
#[test]
|
||||
fn an_art_brush_with_options_comes_in_brushed_with_them() {
|
||||
use vectorcraft_brush::{ArtBrush, ArtScale, Colorization};
|
||||
let check_file = |why: &str, (def, style): (String, String), want: &dyn Fn(&ArtBrush) -> bool| {
|
||||
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
|
||||
let a = art(&r.doc);
|
||||
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, not its look: {a:#?}");
|
||||
assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{why}");
|
||||
let lib = vectorcraft_brush::library(&r.doc);
|
||||
let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("{why}: art") };
|
||||
assert!(want(brush), "{why}: {brush:?}");
|
||||
r
|
||||
};
|
||||
let check = |why: &str, changes: &[(usize, &str)], want: &dyn Fn(&ArtBrush) -> bool| check_file(why, ribbon_with(changes), want);
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
// Settings that don't draw may differ between the brush and the path: the lowest and highest
|
||||
// width while the width is fixed, and lengths without Stretch Between Guides.
|
||||
let (def, style) = ribbon_with(&[(1, "2"), (7, "2"), (8, "2")]);
|
||||
let style = style.replace("2 /Real (MinScaling) ,\n", "").replace("2 /Real (MaxScaling) ,\n", "");
|
||||
check_file("the path leaves out the widths the pen would give", (def, style), &|b| b.width == 200.0);
|
||||
let (def, _) = ribbon_with(&[]);
|
||||
let (_, style) = ribbon_with(&[(11, "5"), (12, "10")]);
|
||||
check_file("lengths on the path alone", (def, style), &|b| b.scale == ArtScale::Stretch);
|
||||
check("its art's colors", &[(0, "0")], &|b| b.colorization == Colorization::None);
|
||||
check("twice as wide", &[(1, "2"), (7, "2"), (8, "2")], &|b| b.width == 200.0);
|
||||
check("half as wide, the pen's widths left", &[(1, "0.5")], &|b| b.width == 50.0 && b.width_mode.is_none());
|
||||
let along = check("flipped along", &[(4, "1")], &|b| b.flip_along && !b.flip_across);
|
||||
let across = check("flipped across", &[(5, "1")], &|b| b.flip_across && !b.flip_along);
|
||||
let (def, style) = ribbon_with(&[(6, "1")]);
|
||||
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
|
||||
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }), "proportional: the drawn look");
|
||||
let lib = vectorcraft_brush::library(&r.doc);
|
||||
let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("proportional: art") };
|
||||
assert_eq!(brush.scale, ArtScale::Proportional);
|
||||
check(
|
||||
"between guides",
|
||||
&[(10, "1"), (11, "5"), (12, "10")],
|
||||
&|b| matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 5.0 / 40.0) && near(end, 30.0 / 40.0)),
|
||||
);
|
||||
check("between guides, flipped along", &[(10, "1"), (11, "5"), (12, "10"), (4, "1")], &|b| {
|
||||
b.flip_along && matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 10.0 / 40.0) && near(end, 35.0 / 40.0))
|
||||
});
|
||||
check("lengths without guides", &[(11, "5"), (12, "10")], &|b| b.scale == ArtScale::Stretch);
|
||||
// Along a straight line, y down: unflipped, the wide block is above the line and the narrow one
|
||||
// below it, at the start.
|
||||
let line = kurbo::BezPath::from_vec(vec![kurbo::PathEl::MoveTo(Point::new(0.0, 0.0)), kurbo::PathEl::LineTo(Point::new(100.0, 0.0))]);
|
||||
let filled = |r: &Structure, x: f64, y: f64| {
|
||||
let lib = vectorcraft_brush::library(&r.doc);
|
||||
let st = art(&r.doc)[0].appearance.stroke().cloned().unwrap();
|
||||
let ribbon = lib.iter().find(|b| b.name == "Ribbon").unwrap();
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&r.doc, ribbon, &line, &st);
|
||||
pieces.iter().any(|n| {
|
||||
let mut hit = false;
|
||||
n.walk(&mut |c| hit |= c.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), Point::new(x, y)) != 0));
|
||||
hit
|
||||
})
|
||||
};
|
||||
// At weight 1 the art is 8 across: each block is 4 across, on its side of the line.
|
||||
assert!(filled(&across, 50.0, 2.0) && filled(&across, 10.0, -2.0) && !filled(&across, 50.0, -2.0), "across: the wide block below");
|
||||
assert!(filled(&along, 50.0, -2.0) && filled(&along, 90.0, 2.0) && !filled(&along, 10.0, 2.0), "along: the narrow block at the end");
|
||||
}
|
||||
|
||||
/// An Art brushed path keeps its drawn look when the brush wouldn't draw it the way the file does:
|
||||
/// its settings aren't those of art that stretches along the path at the brush's own width and
|
||||
/// color, the brush's own aren't, its art isn't read, or the brush isn't one the panel keeps.
|
||||
/// its settings differ from the brush's, the brush's are ones VectorCraft doesn't draw as the file
|
||||
/// does, its art isn't read, or the brush isn't one the panel keeps.
|
||||
#[test]
|
||||
fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
|
||||
let good = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
|
||||
@@ -10486,21 +10616,7 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
|
||||
),
|
||||
plain_style.clone(),
|
||||
),
|
||||
(
|
||||
"a width other than the brush's 100%",
|
||||
format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)),
|
||||
art_style(
|
||||
"Ribbon",
|
||||
1.0,
|
||||
&STRETCHED.map(|e| if matches!(e.0, "Scaling" | "MinScaling" | "MaxScaling") { (e.0, "2", e.2) } else { e }),
|
||||
"",
|
||||
),
|
||||
),
|
||||
(
|
||||
"a brush that keeps its art's colors",
|
||||
format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)),
|
||||
art_style("Ribbon", 1.0, &STRETCHED.map(|e| if e.0 == "Colorization" { (e.0, "0", e.2) } else { e }), ""),
|
||||
),
|
||||
("a brush that keeps its art's colors, the path in Tints", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), plain_style.clone()),
|
||||
("an entry that isn't read", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "1 /Int (SomeOtherSetting) ,\n")),
|
||||
(
|
||||
"an effect on the path",
|
||||
@@ -10513,17 +10629,16 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
|
||||
("proportional", good.clone(), style_with("Proportional", "1")),
|
||||
("between guides", good.clone(), style_with("Segmented", "1")),
|
||||
("its own width", good.clone(), style_with("Scaling", "2")),
|
||||
("a width that varies", good.clone(), style_with("MaxScaling", "2")),
|
||||
("a width on the pen", good.clone(), style_with("ScaleYDependsOn", "2")),
|
||||
("spacing", good.clone(), style_with("Spacing", "1")),
|
||||
("flipped", good.clone(), style_with("UpsideDown", "1")),
|
||||
("a spacing close to the brush's 0", good.clone(), style_with("Spacing", "0.0005")),
|
||||
("flipped",good.clone(), style_with("UpsideDown", "1")),
|
||||
("flipped the other way", good.clone(), style_with("Counterclockwise", "1")),
|
||||
("another adjustment", good.clone(), style_with("Adjustment", "2")),
|
||||
("no fold adjustment", good.clone(), style_with("FoldAdjustment", "0")),
|
||||
("its own colorization", good.clone(), style_with("Colorization", "0")),
|
||||
("tints and shades", good.clone(), style_with("Colorization", "2")),
|
||||
("hue shift", good.clone(), style_with("Colorization", "3")),
|
||||
("start of a guide", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "5 /Real (StartSegmentLength) ,\n")),
|
||||
("variable width", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "2 /Int (BSWidthSource) ,\n")),
|
||||
(
|
||||
"width arrays",
|
||||
@@ -10540,6 +10655,35 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
|
||||
("art not read", plugin_object(ART_BRUSH, "Ribbon", RIBBON), plain_style.clone()),
|
||||
("no such brush", good.clone(), art_style("Other", 1.0, &STRETCHED, "")),
|
||||
];
|
||||
// The brush's own settings, which the path shares, but VectorCraft doesn't draw as the file does.
|
||||
let own = [
|
||||
("both brush and path: tints and shades", &[(0, "2")][..]),
|
||||
("both: hue shift", &[(0, "3")]),
|
||||
("both: spacing", &[(2, "1")]),
|
||||
("both: another adjustment", &[(3, "2")]),
|
||||
("both: no fold adjustment", &[(13, "0")]),
|
||||
("both: a width on the pen", &[(9, "1"), (7, "0.5"), (8, "2")]),
|
||||
("both: a width past the range", &[(1, "20"), (7, "20"), (8, "20")]),
|
||||
("both: a flip of another value", &[(4, "2")]),
|
||||
("both: proportional", &[(6, "1")]),
|
||||
("both: proportional and between guides", &[(6, "1"), (10, "1"), (11, "5"), (12, "10")]),
|
||||
("both: guides that leave no art between them", &[(10, "1"), (11, "30"), (12, "10")]),
|
||||
("both: a guide past the start", &[(10, "1"), (11, "-5"), (12, "10")]),
|
||||
];
|
||||
let mut cases: Vec<(&str, String, String)> = cases
|
||||
.into_iter()
|
||||
.chain(own.iter().map(|(why, changes)| {
|
||||
let (def, style) = ribbon_with(changes);
|
||||
(*why, def, style)
|
||||
}))
|
||||
.collect();
|
||||
// The brush's guides leave art between them, and the path's, close to them, leave none.
|
||||
let (def, style) = ribbon_with(&[(10, "1"), (11, "20"), (12, "19.99")]);
|
||||
cases.push(("path guides close to the brush's that leave no art between them", def, style.replace("19.99 /", "20 /")));
|
||||
// On art 0.01 long, a guide at 0.0035 is 35% of the way along it, and the brush's at 0.003 is 30%.
|
||||
let (def, style) = ribbon_with(&[(10, "1"), (11, "0.003"), (12, "0.002")]);
|
||||
let short = format!("%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 g\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 0.01, 4.0));
|
||||
cases.push(("path guides elsewhere on short art", def.replace(&lopsided_art("Ribbon art"), &short), style.replace("0.003 /", "0.0035 /")));
|
||||
let mut wrong = vec![];
|
||||
for (why, def, style) in cases {
|
||||
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
|
||||
|
||||
@@ -2067,3 +2067,17 @@ fn colr_glyphs_of_outlined_type_are_vector_art() {
|
||||
assert!(rasters(&text).is_empty(), "no images: {text}");
|
||||
assert!(text.contains("1 0 0 Xa"), "the red layer: {text}");
|
||||
}
|
||||
|
||||
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
|
||||
#[test]
|
||||
fn a_stained_instance_is_written_in_its_stained_color() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let art = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), red(), Paint::None);
|
||||
doc.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
|
||||
let mut n = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((50.0, 30.0)) });
|
||||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
|
||||
let l = doc.layers[0].id;
|
||||
doc.insert(Some(l), 0, n).unwrap();
|
||||
let text = String::from_utf8(write(&doc).bytes).unwrap();
|
||||
assert!(text.contains("0 0 1 Xa") && !text.contains("1 0 0 Xa"), "{text}");
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ impl<'a> Writer<'a> {
|
||||
self.warn(LIVE);
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
&& let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
|
||||
&& let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n), *xf)
|
||||
{
|
||||
art.visible = true;
|
||||
self.nest += 1;
|
||||
|
||||
+65
-20
@@ -92,6 +92,19 @@ struct ImageCode {
|
||||
}
|
||||
|
||||
impl ImageCode {
|
||||
/// A hash of everything that is written for the image: equal images have equal keys.
|
||||
fn content_key(&self) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(&self.prelude, &self.dict, self.filter, self.op, &self.ascii).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Is everything written for `other` the same as for this image?
|
||||
fn same_image(&self, other: &ImageCode) -> bool {
|
||||
self.prelude == other.prelude && self.dict == other.dict && self.filter == other.filter && self.op == other.op && self.ascii == other.ascii
|
||||
}
|
||||
|
||||
/// The operators and data that draw the image where it is placed.
|
||||
fn push_inline(&self, s: &mut String) {
|
||||
s.push_str(&self.prelude);
|
||||
@@ -253,6 +266,9 @@ pub(crate) struct Scene<'a> {
|
||||
images: HashMap<String, Option<usize>>,
|
||||
/// The source key and solid paint together identify a colorized encoding.
|
||||
colorized_images: HashMap<(String, String), Option<usize>>,
|
||||
/// Freeform gradients' images by [`ImageCode::content_key`] → their indices in `codes`: a field
|
||||
/// encoded the same as one already kept draws from it.
|
||||
fields: HashMap<u64, Vec<usize>>,
|
||||
/// The encoded images.
|
||||
codes: Vec<ImageCode>,
|
||||
/// Where in `body` each placement of an encoded image goes (in order) and the index of its code.
|
||||
@@ -281,6 +297,7 @@ impl<'a> Scene<'a> {
|
||||
brushes: None,
|
||||
images: HashMap::new(),
|
||||
colorized_images: HashMap::new(),
|
||||
fields: HashMap::new(),
|
||||
codes: vec![],
|
||||
placements: vec![],
|
||||
pending: 0,
|
||||
@@ -478,7 +495,7 @@ impl<'a> Scene<'a> {
|
||||
_ => {}
|
||||
}
|
||||
self.note_losses(n);
|
||||
let (was, placed) = (self.body.len(), self.placements.len());
|
||||
let (was, placed, codes) = (self.body.len(), self.placements.len(), self.codes.len());
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
children.iter().for_each(|c| self.node(c, false));
|
||||
@@ -511,7 +528,7 @@ impl<'a> Scene<'a> {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
&& let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
|
||||
&& let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n), *xf)
|
||||
{
|
||||
self.nest += 1;
|
||||
self.node(&art, true);
|
||||
@@ -536,6 +553,7 @@ impl<'a> Scene<'a> {
|
||||
if self.size() > self.max_body {
|
||||
self.body.truncate(was);
|
||||
self.unplace(placed);
|
||||
self.forget_fields(codes);
|
||||
self.warn(TOO_LARGE);
|
||||
}
|
||||
}
|
||||
@@ -1364,6 +1382,14 @@ impl<'a> Scene<'a> {
|
||||
if !(long > 1e-9 && long.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height());
|
||||
if !invertible(place) {
|
||||
return;
|
||||
}
|
||||
if self.size().saturating_add(64) > self.max_body {
|
||||
self.warn(TOO_LARGE);
|
||||
return;
|
||||
}
|
||||
let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long);
|
||||
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16;
|
||||
let (cols, rows) = (along(area.width()), along(area.height()));
|
||||
@@ -1383,9 +1409,43 @@ impl<'a> Scene<'a> {
|
||||
let rgba: Vec<u8> = field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
|
||||
self.encode_image(&rgba, w, h)
|
||||
};
|
||||
let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height());
|
||||
if let Some(image) = image {
|
||||
self.place_inline(&image, place);
|
||||
let Some(image) = image else { return };
|
||||
let key = image.content_key();
|
||||
let found =
|
||||
self.fields.get(&key).and_then(|bucket| bucket.iter().copied().find(|&i| self.codes.get(i).is_some_and(|c| c.same_image(&image))));
|
||||
let i = match found {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
// A field that can't be placed isn't kept.
|
||||
if self.size().saturating_add(image.inline_len).saturating_add(64) > self.max_body {
|
||||
self.warn(TOO_LARGE);
|
||||
return;
|
||||
}
|
||||
let i = self.add_code(image);
|
||||
self.fields.entry(key).or_default().push(i);
|
||||
i
|
||||
}
|
||||
};
|
||||
self.place_image(i, place);
|
||||
}
|
||||
|
||||
/// Forget the field images after the first `n` codes that no placement draws any longer, last
|
||||
/// first, up to the first code that is drawn or isn't a field image.
|
||||
fn forget_fields(&mut self, n: usize) {
|
||||
while self.codes.len() > n {
|
||||
let i = self.codes.len() - 1;
|
||||
let Some(code) = self.codes.get(i) else { break };
|
||||
if code.uses > 0 {
|
||||
break;
|
||||
}
|
||||
let key = code.content_key();
|
||||
let Some(bucket) = self.fields.get_mut(&key) else { break };
|
||||
let Some(at) = bucket.iter().position(|&j| j == i) else { break };
|
||||
bucket.swap_remove(at);
|
||||
if bucket.is_empty() {
|
||||
self.fields.remove(&key);
|
||||
}
|
||||
self.codes.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1595,21 +1655,6 @@ impl<'a> Scene<'a> {
|
||||
self.line("Q");
|
||||
}
|
||||
|
||||
/// Draw `code`, an image drawn nowhere else, on the unit square mapped by `place`.
|
||||
fn place_inline(&mut self, code: &ImageCode, place: Affine) {
|
||||
if !invertible(place) {
|
||||
return;
|
||||
}
|
||||
if self.size().saturating_add(code.inline_len).saturating_add(64) > self.max_body {
|
||||
self.warn(TOO_LARGE);
|
||||
return;
|
||||
}
|
||||
self.line("q");
|
||||
let _ = writeln!(self.body, "{} cm", ps::matrix(place));
|
||||
code.push_inline(&mut self.body);
|
||||
self.line("Q");
|
||||
}
|
||||
|
||||
/// Straight RGBA pixels (`w` × `h`, rows from the top) as the operators and data that draw
|
||||
/// them on the unit square: in the process colour space, transparent pixels masked out at
|
||||
/// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over
|
||||
|
||||
@@ -1264,6 +1264,104 @@ fn the_page_limit_counts_a_repeated_images_samples_once() {
|
||||
assert!(page.body.len() + page.setup.len() <= inline + 1000 + 400, "{}", page.body.len());
|
||||
}
|
||||
|
||||
/// A freeform gradient of `points` (x, y and color).
|
||||
fn freeform(points: &[(f64, f64, Color)]) -> Paint {
|
||||
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
|
||||
g.freeform = Some(Freeform { points: points.iter().map(|&(x, y, c)| FreeformPoint::new(Point::new(x, y), c)).collect(), ..Default::default() });
|
||||
Paint::Gradient(Box::new(g))
|
||||
}
|
||||
|
||||
/// A square at (`x`, `y`) of side `side`, filled with a freeform gradient of `a` and `b` at a
|
||||
/// fifth and four fifths of its width.
|
||||
fn freeform_square(d: &mut Document, x: f64, y: f64, side: f64, a: Color, b: Color) -> Node {
|
||||
let mid = y + side / 2.0;
|
||||
let paint = freeform(&[(x + side / 5.0, mid, a), (x + side * 0.8, mid, b)]);
|
||||
path(d, shapes::rectangle(Rect::new(x, y, x + side, y + side)), paint, Paint::None, 0.0)
|
||||
}
|
||||
|
||||
/// A freeform gradient on a shape and on a moved copy of it is sampled the same for both: its
|
||||
/// image's samples are written once, at both levels, in RGB and in CMYK.
|
||||
#[test]
|
||||
fn a_freeform_gradients_image_drawn_twice_writes_its_samples_once() {
|
||||
let rgb = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
|
||||
let cmyk = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
for (a, b) in [rgb, cmyk] {
|
||||
let single = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, a, b)]);
|
||||
let twice = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, a, b), freeform_square(d, 64.0, 64.0, 64.0, a, b)]);
|
||||
for level in Level::ALL {
|
||||
let one = export(&single, &EpsOptions { level, ..opts(&single) }, None, None).unwrap();
|
||||
let one_ps = text(&one);
|
||||
assert!(!one_ps.contains("VCim"), "a field drawn once is written where it is drawn: {one_ps}");
|
||||
let out = export(&twice, &EpsOptions { level, ..opts(&twice) }, None, None).unwrap();
|
||||
let ps = text(&out);
|
||||
checked_page(&ps);
|
||||
assert_eq!(reuses(&ps, 0, level), 2, "{ps}");
|
||||
assert!(!ps.contains("VCim1"), "{ps}");
|
||||
let body = &ps[ps.find("%%EndSetup").unwrap()..];
|
||||
assert!(!body.contains("currentfile") && !body.contains("~>"), "no samples in the page: {ps}");
|
||||
assert_eq!(reused_samples(&ps, level), inline_samples(&one_ps, level), "{level:?}");
|
||||
assert_eq!(out.warnings, one.warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields sampled differently are each written where they are drawn: different gradients, and one
|
||||
/// gradient on shapes of different sizes.
|
||||
#[test]
|
||||
fn freeform_gradients_sampled_differently_dont_share_an_image() {
|
||||
let (red, blue, green) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0));
|
||||
let colors = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, green)]);
|
||||
let sizes = doc_with(|d| {
|
||||
let paint = freeform(&[(20.0, 50.0, red), (80.0, 50.0, blue)]);
|
||||
vec![
|
||||
path(d, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint.clone(), Paint::None, 0.0),
|
||||
path(d, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 100.0)), paint, Paint::None, 0.0),
|
||||
]
|
||||
});
|
||||
for d in [colors, sizes] {
|
||||
for level in Level::ALL {
|
||||
let ps = page_of(&d, &EpsOptions { level, ..opts(&d) });
|
||||
assert!(!ps.contains("VCim"), "{ps}");
|
||||
assert_eq!(ps.matches("} exec\n").count(), 2, "{ps}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A field's second placement that passes the page limit is left out, and the first is written
|
||||
/// where it is drawn. A field taken back with the shape that passed the limit leaves nothing in the
|
||||
/// setup.
|
||||
#[test]
|
||||
fn a_field_taken_back_at_the_page_limit_leaves_no_image_behind() {
|
||||
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
|
||||
let single = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue)]);
|
||||
let o = opts(&single);
|
||||
let inline = crate::scene::Scene::new(&single, &o).run().body;
|
||||
let twice = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]);
|
||||
let page = crate::scene::Scene::new(&twice, &o).with_max_body(inline.len() + 50).run();
|
||||
assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings);
|
||||
assert!(page.body.starts_with(&inline), "{}", page.body);
|
||||
assert_eq!(page.body.matches("} exec\n").count(), 1, "{}", page.body);
|
||||
assert!(!page.setup.contains("VCim"), "{}", page.setup);
|
||||
// A many-sided shape whose stroke passes the limit after its fill's field was placed.
|
||||
let big = |d: &mut Document, stroke: Paint| {
|
||||
let paint = freeform(&[(60.0, 100.0, Color::rgb(0.0, 1.0, 0.0)), (140.0, 100.0, red)]);
|
||||
path(d, shapes::polygon(Point::new(100.0, 100.0), 90.0, 2000, 0.0), paint, stroke, 1.0)
|
||||
};
|
||||
let filled = doc_with(|d| vec![big(d, Paint::None)]);
|
||||
let stroked = doc_with(|d| vec![big(d, Paint::solid(Color::rgb(0.0, 0.0, 0.0)))]);
|
||||
let fill = crate::scene::Scene::new(&filled, &o).run().body.len();
|
||||
let both = crate::scene::Scene::new(&stroked, &o).run().body.len();
|
||||
assert!(both > fill + 20_000, "{fill} {both}");
|
||||
let d = doc_with(|d| {
|
||||
let s = big(d, Paint::solid(Color::rgb(0.0, 0.0, 0.0)));
|
||||
vec![s, freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]
|
||||
});
|
||||
let page = crate::scene::Scene::new(&d, &o).with_max_body(fill + (both - fill) / 2).run();
|
||||
assert!(page.warnings.iter().any(|w| w.contains("too large")), "{:?}", page.warnings);
|
||||
assert_eq!(reuses(&page.body, 0, Level::Three), 2, "{}", page.body);
|
||||
assert!(page.setup.contains("/VCim0 VCsrc") && !page.setup.contains("VCim1"), "{}", page.setup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_length_and_ascii85_round_trip() {
|
||||
let cycle: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
|
||||
@@ -1390,6 +1488,20 @@ fn an_instance_whose_art_reaches_the_region_is_written() {
|
||||
assert!(page.contains("1 0 0 rg"), "{page}");
|
||||
}
|
||||
|
||||
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it.
|
||||
#[test]
|
||||
fn a_stained_instance_is_written_in_its_stained_color() {
|
||||
let mut d = doc_with(|d| {
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((50.0, 50.0)) });
|
||||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
|
||||
vec![n]
|
||||
});
|
||||
let art = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
|
||||
let page = page_of(&d, &opts(&d));
|
||||
assert!(page.contains("0 0 1 rg") && !page.contains("1 0 0 rg"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_whose_brush_art_reaches_the_region_is_written() {
|
||||
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt red stroke painted by a 20 pt
|
||||
|
||||
@@ -284,7 +284,7 @@ fn save_file_warns_about_options_it_does_not_use() {
|
||||
}
|
||||
|
||||
/// The notes on `options` name the `.ai` file the save writes, as the note on a top-level option
|
||||
/// does: a legacy `.ai` for `aiVersion: "7"` (beside `pdfCompatible: false` too), a
|
||||
/// does: a legacy `.ai` for `aiVersion: "7"` (beside `pdfCompatible: false` too, which it notes), a
|
||||
/// non-PDF-compatible `.ai` for `pdfCompatible: false`, and a PDF-compatible `.ai` otherwise, also
|
||||
/// when the save reuses the options the document was last saved with.
|
||||
#[test]
|
||||
@@ -301,8 +301,12 @@ fn save_file_notes_name_the_ai_file_it_writes() {
|
||||
("pdf.ai", json!({"pdfCompatible": true, "preview": true}), "PDF-compatible .ai"),
|
||||
] {
|
||||
let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, name), "version": 3, "options": options})));
|
||||
let expected =
|
||||
[format!("`version` was not used: {label} files don't read it"), format!("`options.preview` was not used: {label} files don't read it")];
|
||||
// A legacy `.ai` doesn't read `pdfCompatible` either.
|
||||
let unread = |k: &str| options.get(k).is_some() && (k != "pdfCompatible" || label.starts_with("legacy"));
|
||||
let expected: Vec<String> = std::iter::once("version".to_string())
|
||||
.chain(["pdfCompatible", "preview"].into_iter().filter(|k| unread(k)).map(|k| format!("options.{k}")))
|
||||
.map(|k| format!("`{k}` was not used: {label} files don't read it"))
|
||||
.collect();
|
||||
assert_eq!(notes(&r), expected, "{name}: {r}");
|
||||
}
|
||||
// A save given no option the file reads writes it as last saved: a legacy `.ai` again.
|
||||
|
||||
@@ -1465,7 +1465,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"ui.action.new",
|
||||
"New Action",
|
||||
"",
|
||||
"{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
|
||||
"{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), the color of its button in Button Mode (ui.action.buttonMode). Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
|
||||
),
|
||||
(
|
||||
"ui.action.options",
|
||||
@@ -1479,6 +1479,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays",
|
||||
),
|
||||
(
|
||||
"ui.action.playAt",
|
||||
"Play Action",
|
||||
"",
|
||||
"{set: index, action: index} play the Actions panel's action at action in set (0 is the top set) as ui.action.play plays it, and return what ui.action.play returns. A click on an action's button in Button Mode runs this. A missing index, or one that is not a whole number or is past the last set or action, is an error and nothing plays",
|
||||
),
|
||||
(
|
||||
"ui.action.buttonMode",
|
||||
"Button Mode",
|
||||
"",
|
||||
"{enabled?: bool} with true, the Actions panel shows every action of every set, in set order and then action order, as a button with its name in its Action Options color (no color: an ordinary button), without set names, steps or checkmarks, and a click on a button plays its action (ui.action.playAt); with false, it shows its list again; left out or null, it switches to the other view. The panel menu's Button Mode runs this and shows a check mark while it is on. The view is kept with the UI preferences. Turning it on while an action records, or an enabled that is not true, false or null, is an error and the view stays → {enabled}",
|
||||
),
|
||||
(
|
||||
"ui.action.playbackOptions",
|
||||
"Playback Options",
|
||||
|
||||
@@ -217,7 +217,7 @@ impl<'a> Scene<'a> {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
&& let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n)
|
||||
&& let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n)
|
||||
&& let Some(art) =
|
||||
if self.clips { self.doc.place_symbol_art(symbol, &art, *xf) } else { self.doc.place_symbol_art_unclipped(symbol, &art, *xf) }
|
||||
{
|
||||
|
||||
@@ -399,6 +399,22 @@ fn an_instance_whose_art_reaches_the_artboard_is_written() {
|
||||
assert!(k.contains(&39), "a brush fills the art: {k:?}");
|
||||
}
|
||||
|
||||
/// An instance stained blue by its fill writes its symbol's red art in blue, as the canvas draws it,
|
||||
/// in EMF (clipped) and in WMF (unclipped).
|
||||
#[test]
|
||||
fn a_stained_instance_is_written_in_its_stained_color() {
|
||||
let mut inst = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Mark".into(), xf: Affine::translate((30.0, 30.0)) });
|
||||
inst.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0).into();
|
||||
let mut d = doc_with(100.0, 100.0, vec![inst]);
|
||||
let art = path(shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), red(), Paint::None, 0.0);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) });
|
||||
for clips in [true, false] {
|
||||
let (ops, _) = crate::scene::Scene::new(&d, d.artboards[0].rect, clips).run();
|
||||
let colors: Vec<[u8; 3]> = fills(&ops).into_iter().map(|(_, rgb)| rgb).collect();
|
||||
assert_eq!(colors, [[0, 0, 255]], "clips: {clips}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern `name` repeating `art` every point.
|
||||
fn point_tiles(name: &str, art: Node) -> PatternDef {
|
||||
let mut def = PatternDef::new(name, vec![Arc::new(art)]);
|
||||
|
||||
@@ -259,8 +259,9 @@ mod tests {
|
||||
#[test]
|
||||
fn offset_path_sweeps_a_long_low_path_turned_with_the_result_of_the_path_on_end() {
|
||||
let mut rng = Rng(17);
|
||||
// Grown past the zigzag's bends, where the stroker leaves loops whose faces are dropped by
|
||||
// the distance test; grown a little; and inset.
|
||||
// An open zigzag grown by 1.5, a closed one grown by 0.5 and a closed one inset by 0.3. Each
|
||||
// is swept turned when low and as it is when stood on end, and the two results cover the
|
||||
// same region. None of the three leaves a face for the distance test to drop.
|
||||
for (closed, delta) in [(false, 1.5), (true, 0.5), (true, -0.3)] {
|
||||
let what = format!("closed {closed}, offset {delta}");
|
||||
let low = zigzag(3, 300, 0.4, &mut rng, closed);
|
||||
@@ -274,7 +275,8 @@ mod tests {
|
||||
assert_same_region(&turned, &upright(&on_end), &what);
|
||||
}
|
||||
// A long plate with a row of round holes: growing it by 1 closes the narrow holes, which
|
||||
// the distance test drops, and keeps the wide ones.
|
||||
// the distance test drops when it measures each hole's point turned back into the plate's
|
||||
// own frame, and keeps the wide ones.
|
||||
use kurbo::Shape;
|
||||
let mut plate = kurbo::Rect::new(0.0, 0.0, 4000.0, 20.0).to_path(0.1);
|
||||
for i in 0..200u32 {
|
||||
|
||||
@@ -46,7 +46,7 @@ use exporter::{MAX_COORD, writable, xf};
|
||||
#[cfg(test)]
|
||||
use images::{png_8_bit, png_16_bit};
|
||||
#[cfg(test)]
|
||||
pub(crate) use nodes::MEASURES;
|
||||
pub(crate) use nodes::{KNOCKOUT_BRUSHES, KNOCKOUT_SHAPES, MAX_KNOCKOUT_MASK_OBJECTS, MEASURES};
|
||||
pub(crate) use writer::{Sheet, Writer};
|
||||
|
||||
pub(crate) struct Exporter<'a> {
|
||||
@@ -127,6 +127,14 @@ pub(crate) struct Exporter<'a> {
|
||||
/// The most [`Self::symbol_nodes`] may reach: [`vectorcraft_doc::MAX_SYMBOL_NODES`], or less
|
||||
/// while one of the copies on a sheet is drawn ([`Writer::page`]).
|
||||
pub(crate) symbol_limit: u64,
|
||||
/// Objects the symbol instances in the copies drawn in knockout masks held, together: counted
|
||||
/// apart from [`Self::symbol_nodes`], within [`vectorcraft_doc::MAX_SYMBOL_NODES`] of their own.
|
||||
mask_symbol_nodes: u64,
|
||||
/// Whether a knockout mask is being drawn: [`Self::symbol_nodes`] then counts
|
||||
/// [`Self::mask_symbol_nodes`].
|
||||
in_knockout_mask: bool,
|
||||
/// Whether a knockout mask left out symbol art because the objects its copies may hold ran out.
|
||||
mask_art_left_out: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -54,6 +54,9 @@ impl<'a> Exporter<'a> {
|
||||
paper: Rect::ZERO,
|
||||
symbol_nodes: 0,
|
||||
symbol_limit: vectorcraft_doc::MAX_SYMBOL_NODES,
|
||||
mask_symbol_nodes: 0,
|
||||
in_knockout_mask: false,
|
||||
mask_art_left_out: false,
|
||||
overprint: false,
|
||||
overprinted: false,
|
||||
invisible: false,
|
||||
|
||||
+549
-65
@@ -1,7 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::exporter::{UNDECODED, address, blend, color, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable, xf};
|
||||
use super::{
|
||||
Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, GradientKind, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32,
|
||||
Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
|
||||
Affine, AppearanceItem, BezPath, BlendMode, Document, Exporter, Fill, FillRule, GradientKind, LineCap, LineJoin, Mark, Node, NodeKind,
|
||||
NormalizedF32, Paint, Path, Rect, Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
|
||||
};
|
||||
|
||||
/// An object's visual box for leaving out what is off the page. A plain group or layer also holds
|
||||
@@ -57,88 +59,146 @@ impl Exporter<'_> {
|
||||
|
||||
/// The children of a group, as the elements of a knockout group when it is one.
|
||||
pub(super) fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect) {
|
||||
self.measured_children(s, children, page, &[]);
|
||||
self.measured_children(s, children, page, &[], false);
|
||||
}
|
||||
|
||||
/// [`Self::children`] with the boxes of the last members in `measured`.
|
||||
fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) {
|
||||
/// [`Self::children`] with the boxes of the last members in `measured`, in an isolated group
|
||||
/// when `isolated`.
|
||||
fn measured_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured], isolated: bool) {
|
||||
if !self.knockout {
|
||||
if isolated {
|
||||
s.push_isolated();
|
||||
}
|
||||
let unmeasured = children.len().saturating_sub(measured.len());
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
self.measured_node(s, c, page, false, i.checked_sub(unmeasured).and_then(|i| measured.get(i)));
|
||||
}
|
||||
if isolated {
|
||||
s.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let elements: Vec<_> = Node::knockout_elements(children)
|
||||
let elements: Vec<(&Node, Measured)> = Node::knockout_elements(children)
|
||||
.into_iter()
|
||||
.map(|c| (c, self.measure(c)))
|
||||
.map(|c| (&**c, self.measure(c)))
|
||||
.filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page)))
|
||||
.collect();
|
||||
if !elements.is_empty() && elements.iter().all(|(c, _)| c.knockout_outline().is_some()) {
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
|
||||
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
|
||||
return self.warp_pieces(s, &elements, page);
|
||||
if elements.is_empty() {
|
||||
return;
|
||||
}
|
||||
if elements.iter().all(|(c, _)| c.knockout_outline().is_some()) {
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| *c).collect();
|
||||
if isolated {
|
||||
s.push_isolated();
|
||||
}
|
||||
for (c, m) in &elements {
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
|
||||
self.warp_pieces(s, &elements, page);
|
||||
} else {
|
||||
for (c, m) in &elements {
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
}
|
||||
}
|
||||
if isolated {
|
||||
s.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The PDF writer has no knockout groups: each element is drawn through a soft mask of
|
||||
// where the elements above it don't paint (the same look for Normal blending). The masks
|
||||
// nest, so element i sits inside the masks of elements i+1…n: open them outermost first.
|
||||
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
|
||||
let Some(((first, measured), rest)) = elements.split_first() else { return };
|
||||
for (c, m) in rest.iter().rev() {
|
||||
let mask = self.knockout_mask(s, c, page, m);
|
||||
s.push_mask(mask);
|
||||
let several = elements.len() > 1;
|
||||
// A PDF knockout group (`/K`) knocks out by the area each element paints. Where an element
|
||||
// above another knocks out by less (its opacity or mask defines its knockout shape, or it
|
||||
// paints translucently), each element is drawn through one soft mask of where the
|
||||
// elements above it that overlap it paint, while those masks hold no more than
|
||||
// [`MAX_KNOCKOUT_MASK_OBJECTS`] objects and no brushed stroke.
|
||||
if several && !elements.iter().skip(1).all(|(c, _)| shape_is_coverage(c)) {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
match knockout_masks(BrushSearch::new(doc, brushes), &elements) {
|
||||
Ok(above) => {
|
||||
// Every mask is made before anything is drawn: when the symbol objects the
|
||||
// masks may hold run out, one would leave out art its element covers, and
|
||||
// the group is written as a PDF knockout group instead.
|
||||
let outer = std::mem::take(&mut self.mask_art_left_out);
|
||||
let before = *self.mask_counter();
|
||||
let mut masks = Vec::with_capacity(above.len());
|
||||
for above in above {
|
||||
let shapes: Vec<_> = above.into_iter().map(|j| &elements[j]).collect();
|
||||
masks.push((!shapes.is_empty()).then(|| self.knockout_mask(s, &shapes, page)));
|
||||
}
|
||||
let left_out = std::mem::replace(&mut self.mask_art_left_out, outer);
|
||||
if left_out {
|
||||
*self.mask_counter() = before;
|
||||
self.warn(KNOCKOUT_SHAPES);
|
||||
} else {
|
||||
if isolated {
|
||||
s.push_isolated();
|
||||
}
|
||||
for ((c, m), mask) in elements.iter().zip(masks) {
|
||||
if let Some(mask) = mask {
|
||||
s.push_mask(mask);
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
s.pop();
|
||||
} else {
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
}
|
||||
}
|
||||
if isolated {
|
||||
s.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(warning) => self.warn(warning),
|
||||
}
|
||||
}
|
||||
self.measured_node(s, first, page, false, Some(measured));
|
||||
for (c, m) in rest {
|
||||
s.pop();
|
||||
let group = several || isolated;
|
||||
if group {
|
||||
s.push_group(krilla::blend::GroupOptions { isolated, knockout: several });
|
||||
}
|
||||
for (c, m) in &elements {
|
||||
// In a PDF knockout group each painting operation knocks out the ones before it. An
|
||||
// element drawn with several is one non-isolated group (drawn through a full alpha
|
||||
// mask), so that its own fills and strokes don't knock each other out; its opacity
|
||||
// or opacity mask makes it one already.
|
||||
let unit = several && !one_paint(c) && c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled);
|
||||
if unit {
|
||||
let all = constant_mask(s, page, 1.0);
|
||||
s.push_mask(all);
|
||||
}
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
if unit {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
if group {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::measured_children`] of a plain group or layer. When they are the pieces of a
|
||||
/// mesh-warped image, every piece, on the page or not, takes part in the one size its pixels
|
||||
/// are resampled for ([`Self::warp_image_size`]).
|
||||
fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) {
|
||||
fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured], isolated: bool) {
|
||||
let outer = self.warp_image_size(children).map(|w| self.warp_size.replace(w));
|
||||
self.measured_children(s, children, page, measured);
|
||||
self.measured_children(s, children, page, measured, isolated);
|
||||
if let Some(outer) = outer {
|
||||
self.warp_size = outer;
|
||||
}
|
||||
}
|
||||
|
||||
/// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white,
|
||||
/// then black through an alpha mask of `c` (at full object opacity without its own mask, unless
|
||||
/// those define its shape).
|
||||
fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect, measured: &Measured) -> krilla::mask::Mask {
|
||||
let shape = if c.knockout_shape {
|
||||
c.clone()
|
||||
} else {
|
||||
let mut s = c.clone().with_mask(None);
|
||||
s.opacity = 1.0;
|
||||
s
|
||||
};
|
||||
self.shapes_mask(s, &[(&shape, Some(measured))], page)
|
||||
}
|
||||
|
||||
/// The pieces of a partly transparent mesh-warped image (`elements`, each with its box) as the
|
||||
/// elements of a knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece
|
||||
/// is drawn through one soft mask of where the pieces above it that reach its box don't cover
|
||||
/// ([`vectorcraft_effects::knockout_silhouette`]): only neighbors overlap, so each mask holds a
|
||||
/// few triangles and no mask nests in another. A piece above that overlaps it only over opaque
|
||||
/// pixels ([`vectorcraft_effects::WarpSeams`]) is left out of its mask.
|
||||
fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&std::sync::Arc<Node>, Measured)], page: Rect) {
|
||||
fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&Node, Measured)], page: Rect) {
|
||||
// Each piece's triangle and its box: a piece paints inside its triangle only.
|
||||
let silhouette = |c: &Node| {
|
||||
let b = c.knockout_outline()?.geometric_bounds()?;
|
||||
Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::WHITE)?, b))
|
||||
};
|
||||
let silhouettes: Vec<Option<(Node, Rect)>> = elements.iter().map(|(c, _)| silhouette(c)).collect();
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| *c).collect();
|
||||
let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces);
|
||||
for (i, (c, measured)) in elements.iter().enumerate() {
|
||||
let Some((_, reach)) = &silhouettes[i] else { continue };
|
||||
@@ -191,6 +251,67 @@ impl Exporter<'_> {
|
||||
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
|
||||
}
|
||||
|
||||
/// A luminosity mask that is 1 − the knockout shapes of `shapes` (each with its box): white,
|
||||
/// then black through an alpha mask of each (at full object opacity without its own mask,
|
||||
/// unless those define its shape). The symbol instances in the copies count toward
|
||||
/// [`Exporter::mask_symbol_nodes`], not toward the objects the visible art may draw. A
|
||||
/// knockout mask drawn inside another one counts toward the same objects as the outer one.
|
||||
fn knockout_mask(&mut self, s: &mut Surface, shapes: &[&(&Node, Measured)], page: Rect) -> krilla::mask::Mask {
|
||||
if self.in_knockout_mask {
|
||||
return self.knockout_mask_art(s, shapes, page);
|
||||
}
|
||||
self.in_knockout_mask = true;
|
||||
let visible = std::mem::replace(&mut self.symbol_nodes, self.mask_symbol_nodes);
|
||||
let limit = std::mem::replace(&mut self.symbol_limit, vectorcraft_doc::MAX_SYMBOL_NODES);
|
||||
let mask = self.knockout_mask_art(s, shapes, page);
|
||||
self.mask_symbol_nodes = std::mem::replace(&mut self.symbol_nodes, visible);
|
||||
self.symbol_limit = limit;
|
||||
self.in_knockout_mask = false;
|
||||
mask
|
||||
}
|
||||
|
||||
/// The symbol objects the copies drawn in knockout masks held, together: [`Self::symbol_nodes`]
|
||||
/// while a knockout mask is being drawn, [`Exporter::mask_symbol_nodes`] otherwise.
|
||||
fn mask_counter(&mut self) -> &mut u64 {
|
||||
if self.in_knockout_mask { &mut self.symbol_nodes } else { &mut self.mask_symbol_nodes }
|
||||
}
|
||||
|
||||
/// [`Self::knockout_mask`], drawn within the symbol objects counted at the time.
|
||||
fn knockout_mask_art(&mut self, s: &mut Surface, shapes: &[&(&Node, Measured)], page: Rect) -> krilla::mask::Mask {
|
||||
let transform = s.group_transform();
|
||||
let mut sb = s.stream_builder();
|
||||
let mut ms = Surface::stream(sb.surface());
|
||||
ms.push_transform(&transform);
|
||||
cover(&mut ms, page, 255, 1.0);
|
||||
for (c, measured) in shapes.iter().map(|e| (e.0, &e.1)) {
|
||||
let shape = if c.knockout_shape {
|
||||
c.clone()
|
||||
} else {
|
||||
let mut s = c.clone().with_mask(None);
|
||||
s.opacity = 1.0;
|
||||
s
|
||||
};
|
||||
let alpha = {
|
||||
let transform = ms.group_transform();
|
||||
let mut ab = ms.stream_builder();
|
||||
let mut als = Surface::stream(ab.surface());
|
||||
als.push_transform(&transform);
|
||||
let overprint = std::mem::take(&mut self.overprint);
|
||||
self.measured_node(&mut als, &shape, page, false, Some(measured));
|
||||
self.overprint = overprint;
|
||||
als.pop();
|
||||
als.finish();
|
||||
krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha)
|
||||
};
|
||||
ms.push_mask(alpha);
|
||||
cover(&mut ms, page, 0, 1.0);
|
||||
ms.pop();
|
||||
}
|
||||
ms.pop();
|
||||
ms.finish();
|
||||
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
|
||||
}
|
||||
|
||||
/// The box of `n`, and of the members of a plain group or layer.
|
||||
fn measure(&self, n: &Node) -> Measured {
|
||||
if !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
|
||||
@@ -242,23 +363,7 @@ impl Exporter<'_> {
|
||||
// Whether this container's children knock each other out (a knockout group is written as a group).
|
||||
let knockout = n.knocks_out(self.knockout);
|
||||
let enclosing = std::mem::replace(&mut self.knockout, knockout);
|
||||
let mut pushes = 0;
|
||||
if n.blend != BlendMode::Normal {
|
||||
s.push_blend_mode(blend(n.blend));
|
||||
pushes += 1;
|
||||
}
|
||||
if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) {
|
||||
if !n.isolate && n.blends_through() {
|
||||
// Not isolated: blending inside reaches the art below the group.
|
||||
let alpha = constant_mask(s, page, n.opacity);
|
||||
s.push_mask(alpha);
|
||||
} else if n.opacity < 1.0 {
|
||||
s.push_opacity(norm(n.opacity));
|
||||
} else {
|
||||
s.push_isolated();
|
||||
}
|
||||
pushes += 1;
|
||||
}
|
||||
let (mut pushes, isolated_children) = transparency(s, n, page, container, knockout);
|
||||
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
|
||||
let alpha = match (&n.kind, mask) {
|
||||
(NodeKind::Image(im), Some(m)) => self.masked_image(im, m, !n.extras().pixelated),
|
||||
@@ -279,7 +384,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
|
||||
self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()), isolated_children);
|
||||
}
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// The region every output clips to; nothing to clip by hides the clipped art. The
|
||||
@@ -336,14 +441,15 @@ impl Exporter<'_> {
|
||||
// instances multiply.
|
||||
// Placing through 9-slice guides needs room for the most objects it can make
|
||||
// (`NineSliceMap::most_placed`), and the objects it makes count.
|
||||
// A dynamic symbol's instance shows its own appearance changes.
|
||||
// A dynamic symbol's instance shows its own appearance changes, and the instance's
|
||||
// fill stains its art, as on the canvas.
|
||||
let doc = self.doc;
|
||||
if let Some(sym) = doc.symbols.iter().find(|x| &x.name == symbol) {
|
||||
let changed;
|
||||
let art: &Node = if n.extras().symbol_overrides.is_empty() {
|
||||
let art: &Node = if n.extras().symbol_overrides.is_empty() && vectorcraft_doc::symbol_overrides::stain(n).is_none() {
|
||||
&sym.art
|
||||
} else {
|
||||
changed = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
changed = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
|
||||
&changed
|
||||
};
|
||||
let count = art.count() as u64;
|
||||
@@ -365,8 +471,10 @@ impl Exporter<'_> {
|
||||
(art, count)
|
||||
})
|
||||
});
|
||||
if let Some(art) = placed.flatten() {
|
||||
self.node(s, &art, page, true);
|
||||
match placed.flatten() {
|
||||
Some(art) => self.node(s, &art, page, true),
|
||||
// A knockout mask that leaves the art out no longer matches what it masks.
|
||||
None => self.mask_art_left_out |= self.in_knockout_mask,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -740,3 +848,379 @@ impl Exporter<'_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the blend mode and the transparency group `n` is drawn with (`container` when it is a
|
||||
/// group or layer whose children `knockout` each other). Returns how many were opened, and
|
||||
/// whether the children are to be drawn in an isolated group of their own: the knockout group of
|
||||
/// an unclipped group's elements is its isolated group too.
|
||||
fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout: bool) -> (usize, bool) {
|
||||
let mut pushes = 0;
|
||||
if n.blend != BlendMode::Normal {
|
||||
s.push_blend_mode(blend(n.blend));
|
||||
pushes += 1;
|
||||
}
|
||||
let mut isolated_children = false;
|
||||
if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) {
|
||||
if !n.isolate && n.blends_through() {
|
||||
// Not isolated: blending inside reaches the art below the group.
|
||||
let alpha = constant_mask(s, page, n.opacity);
|
||||
s.push_mask(alpha);
|
||||
pushes += 1;
|
||||
} else if n.opacity < 1.0 {
|
||||
s.push_opacity(norm(n.opacity));
|
||||
pushes += 1;
|
||||
} else if knockout && matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
|
||||
isolated_children = true;
|
||||
} else {
|
||||
s.push_isolated();
|
||||
pushes += 1;
|
||||
}
|
||||
}
|
||||
(pushes, isolated_children)
|
||||
}
|
||||
|
||||
/// The warning for a knockout group whose elements are too many to draw through soft masks: it is
|
||||
/// written as a PDF knockout group, where an element knocks out all of the area it paints.
|
||||
pub(crate) const KNOCKOUT_SHAPES: &str = "knockout groups with too many overlapping translucent elements are written as PDF knockout groups: \
|
||||
their translucent elements knock out what they cover fully";
|
||||
|
||||
/// The most objects the soft masks of one knockout group's elements draw between them: each mask
|
||||
/// draws the elements above its own element that overlap it.
|
||||
pub(crate) const MAX_KNOCKOUT_MASK_OBJECTS: usize = 1 << 16;
|
||||
|
||||
/// The most elements of one knockout group drawn through soft masks: finding the elements that
|
||||
/// overlap compares every pair.
|
||||
pub(crate) const MAX_MASKED_ELEMENTS: usize = 2048;
|
||||
|
||||
/// The warning for a knockout group in which a soft mask would draw an element with a brushed
|
||||
/// stroke: it is written as a PDF knockout group, where an element knocks out all of the area it
|
||||
/// paints.
|
||||
pub(crate) const KNOCKOUT_BRUSHES: &str = "knockout groups with brushed strokes over other translucent elements are written as PDF knockout groups: \
|
||||
their translucent elements knock out what they cover fully";
|
||||
|
||||
/// For each element of a knockout group (with its box), the indices of the elements above it whose
|
||||
/// boxes overlap its own. The warning to give instead ([`KNOCKOUT_SHAPES`]) when there are more than
|
||||
/// [`MAX_MASKED_ELEMENTS`] elements or the masks would draw more than
|
||||
/// [`MAX_KNOCKOUT_MASK_OBJECTS`] objects, or ([`KNOCKOUT_BRUSHES`]) when a mask would draw a
|
||||
/// brushed stroke ([`BrushSearch`]), whose brush art the object count leaves out.
|
||||
fn knockout_masks(mut search: BrushSearch, elements: &[(&Node, Measured)]) -> Result<Vec<Vec<usize>>, &'static str> {
|
||||
if elements.len() > MAX_MASKED_ELEMENTS {
|
||||
return Err(KNOCKOUT_SHAPES);
|
||||
}
|
||||
let counts: Vec<usize> = elements.iter().map(|(c, _)| c.count_with_masks()).collect();
|
||||
let mut checked: Vec<Option<bool>> = vec![None; elements.len()];
|
||||
let mut objects = 0usize;
|
||||
let mut out = Vec::with_capacity(elements.len());
|
||||
for (i, (_, below)) in elements.iter().enumerate() {
|
||||
let mut above = vec![];
|
||||
for (j, (over_node, over)) in elements.iter().enumerate().skip(i + 1) {
|
||||
if let (Some(a), Some(b)) = (below.bounds, over.bounds)
|
||||
&& rects_overlap(a.inflate(1.0, 1.0), b.inflate(1.0, 1.0))
|
||||
{
|
||||
// The mask draws a copy of the element without its own opacity mask, unless the
|
||||
// mask defines its knockout shape.
|
||||
if *checked[j].get_or_insert_with(|| search.node(over_node, over_node.knockout_shape, 0)) {
|
||||
return Err(if search.exhausted { KNOCKOUT_SHAPES } else { KNOCKOUT_BRUSHES });
|
||||
}
|
||||
objects = objects.checked_add(counts[j]).filter(|&o| o <= MAX_KNOCKOUT_MASK_OBJECTS).ok_or(KNOCKOUT_SHAPES)?;
|
||||
above.push(j);
|
||||
}
|
||||
}
|
||||
out.push(above);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The art a [`BrushSearch`] looks through once: a symbol's or a pattern's, by name, or a
|
||||
/// symbol's with one instance's appearance changes, by the symbol's name and the changes.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum SharedArt {
|
||||
Symbol,
|
||||
Pattern,
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// A search for the brushed strokes drawing an object draws, whose brush art can hold many more
|
||||
/// objects than the stroked path.
|
||||
struct BrushSearch<'a> {
|
||||
doc: &'a Document,
|
||||
/// The brushes the export draws strokes with ([`Exporter::brush_art`]); a stroke whose brush
|
||||
/// is not among them is drawn as a plain stroke.
|
||||
brushes: &'a [vectorcraft_brush::Brush],
|
||||
/// The answer for each symbol's and pattern's art, and each symbol's art with an instance's
|
||||
/// appearance changes, once found: false while it is being looked through, so that art that
|
||||
/// uses itself ends the search.
|
||||
seen: HashMap<(SharedArt, String), bool>,
|
||||
/// The objects the search may still visit or copy: [`MAX_KNOCKOUT_MASK_OBJECTS`] at the start.
|
||||
left: usize,
|
||||
/// Whether the search stopped because [`Self::left`] ran out; it then answers true.
|
||||
exhausted: bool,
|
||||
}
|
||||
|
||||
impl<'a> BrushSearch<'a> {
|
||||
fn new(doc: &'a Document, brushes: &'a [vectorcraft_brush::Brush]) -> Self {
|
||||
Self { doc, brushes, seen: HashMap::new(), left: MAX_KNOCKOUT_MASK_OBJECTS, exhausted: false }
|
||||
}
|
||||
|
||||
/// Takes `n` objects from [`Self::left`]; false, and the search exhausted, when fewer are left.
|
||||
fn spend(&mut self, n: usize) -> bool {
|
||||
match self.left.checked_sub(n) {
|
||||
Some(left) if !self.exhausted => {
|
||||
self.left = left;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
(self.left, self.exhausted) = (0, true);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the objects of `n` (its members and opacity-mask art included) from [`Self::left`],
|
||||
/// counting no further than what is left; false when they don't fit.
|
||||
fn spend_tree(&mut self, n: &Node) -> bool {
|
||||
self.spend(1)
|
||||
&& n.extras().mask.as_deref().is_none_or(|m| self.spend_tree(&m.art))
|
||||
&& n.children().into_iter().flatten().all(|c| self.spend_tree(c))
|
||||
}
|
||||
|
||||
/// Whether drawing `n` draws a brushed stroke: on `n` or its visible members (a compound
|
||||
/// path's members are drawn with its own paint), in the art of the opacity masks that are on
|
||||
/// (`n`'s own when `own_mask`), of symbol instances and inline graphics, of live objects as
|
||||
/// evaluated, and of the patterns that fills, strokes and type paint with. `depth` counts the
|
||||
/// objects and the mask, symbol and pattern art above `n`: past
|
||||
/// [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`] the search stops and counts as a brush, and so
|
||||
/// it does when the objects it may visit or copy ([`Self::left`]) run out.
|
||||
fn node(&mut self, n: &Node, own_mask: bool, depth: usize) -> bool {
|
||||
if depth > vectorcraft_doc::depth::MAX_DRAWN_DEPTH || !self.spend(1) {
|
||||
return true;
|
||||
}
|
||||
let depth = depth + 1;
|
||||
// A fill is drawn when it is visible, and a stroke when it also paints and has a width
|
||||
// (as `Exporter::shape` draws them).
|
||||
for item in n.appearance.items.iter().filter(|i| i.visible()) {
|
||||
let paint = match item {
|
||||
AppearanceItem::Fill(f) => &f.paint,
|
||||
AppearanceItem::Stroke(st) if st.paint.is_none() || st.width <= 0.0 => continue,
|
||||
AppearanceItem::Stroke(st) => {
|
||||
if st.brush.as_deref().is_some_and(|name| self.brushes.iter().any(|b| b.name == name)) {
|
||||
return true;
|
||||
}
|
||||
&st.paint
|
||||
}
|
||||
};
|
||||
if self.paint(paint, depth) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if own_mask && n.extras().mask.as_deref().is_some_and(|m| !m.disabled && self.node(&m.art, true, depth)) {
|
||||
return true;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
// A dynamic symbol's instance draws its art with its own appearance changes: the
|
||||
// art is copied once for each set of changes, counted before it is copied.
|
||||
let doc = self.doc;
|
||||
match doc.symbols.iter().find(|s| &s.name == symbol) {
|
||||
Some(s) if !n.extras().symbol_overrides.is_empty() => {
|
||||
let key = format!("{symbol}\u{0}{:?}", n.extras().symbol_overrides);
|
||||
self.shared(SharedArt::Dynamic, &key, |search| {
|
||||
!search.spend_tree(&s.art) || search.node(&vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n), true, depth)
|
||||
})
|
||||
}
|
||||
_ => self.symbol(symbol, depth),
|
||||
}
|
||||
}
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| {
|
||||
let stroke = r.style.stroke_width > 0.0 && self.paint(&r.style.stroke, depth);
|
||||
stroke || self.paint(&r.style.fill, depth) || r.inline.as_ref().is_some_and(|ig| self.symbol(&ig.symbol, depth))
|
||||
}),
|
||||
NodeKind::Compound { .. } => false,
|
||||
// Live objects are drawn as their evaluated art, as `Exporter::measured_node` draws them.
|
||||
NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. }
|
||||
| NodeKind::Mesh(_)
|
||||
| NodeKind::Repeat(_)
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::Intertwine { .. } => {
|
||||
// Each live object is reached once in the art searched (shared art is searched
|
||||
// once); its evaluated art is counted before it is searched.
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
!self.spend_tree(&g) || g.children().into_iter().flatten().filter(|c| c.visible).any(|c| self.node(c, true, depth))
|
||||
}
|
||||
_ => n.children().into_iter().flatten().filter(|c| c.visible).any(|c| self.node(c, true, depth)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the art of the symbol named `name` draws a brushed stroke.
|
||||
fn symbol(&mut self, name: &str, depth: usize) -> bool {
|
||||
let doc = self.doc;
|
||||
let Some(s) = doc.symbols.iter().find(|s| s.name == name) else { return false };
|
||||
self.shared(SharedArt::Symbol, name, |search| search.node(&s.art, true, depth))
|
||||
}
|
||||
|
||||
/// Whether `paint` is a pattern whose tile art draws a brushed stroke.
|
||||
fn paint(&mut self, paint: &Paint, depth: usize) -> bool {
|
||||
let Paint::Pattern { pattern, .. } = paint else { return false };
|
||||
let doc = self.doc;
|
||||
let Some(def) = doc.pattern(pattern) else { return false };
|
||||
self.shared(SharedArt::Pattern, pattern, |search| def.art.iter().filter(|c| c.visible).any(|c| search.node(c, true, depth)))
|
||||
}
|
||||
|
||||
/// The answer of `search` for the shared art `kind` named `name`, found once.
|
||||
fn shared(&mut self, kind: SharedArt, name: &str, search: impl FnOnce(&mut Self) -> bool) -> bool {
|
||||
let key = (kind, name.to_string());
|
||||
if let Some(&b) = self.seen.get(&key) {
|
||||
return b;
|
||||
}
|
||||
self.seen.insert(key.clone(), false);
|
||||
let b = search(self);
|
||||
self.seen.insert(key, b);
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `n` is drawn with one painting operation at most: a path or compound path with at most
|
||||
/// one visible fill or stroke that paints, in a solid color, without effects, a brush or
|
||||
/// arrowheads.
|
||||
fn one_paint(n: &Node) -> bool {
|
||||
let mut items = n.appearance.items.iter().filter(|i| i.visible() && !matches!(i.paint(), Paint::None));
|
||||
let simple = items.next().is_none_or(|i| {
|
||||
let plain = match i {
|
||||
AppearanceItem::Fill(_) => true,
|
||||
AppearanceItem::Stroke(st) => st.brush.is_none() && st.start_arrow.is_none() && st.end_arrow.is_none(),
|
||||
};
|
||||
plain && i.effects().is_empty() && matches!(i.paint(), Paint::Solid { .. })
|
||||
});
|
||||
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && n.appearance.effects.is_empty() && simple && items.next().is_none()
|
||||
}
|
||||
|
||||
/// Whether element `c` of a knockout group knocks out all of the area it paints, as an element of
|
||||
/// a PDF knockout group does: its opacity and opacity mask are full or don't define its knockout
|
||||
/// shape, and every part of it paints opaquely.
|
||||
fn shape_is_coverage(c: &Node) -> bool {
|
||||
let own = !c.knockout_shape || (c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled));
|
||||
own && paints_opaque(c)
|
||||
}
|
||||
|
||||
/// Whether what `n` paints is opaque wherever it paints, at its own opacity and mask: its visible
|
||||
/// fills and strokes are solid colors or gradients with opaque stops, without effects or brushes,
|
||||
/// and so are the members of a group or compound path, at full opacity without an opacity mask.
|
||||
fn paints_opaque(n: &Node) -> bool {
|
||||
let paint = |p: &Paint| match p {
|
||||
Paint::None | Paint::Solid { .. } => true,
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0),
|
||||
_ => false,
|
||||
};
|
||||
let items = n.appearance.items.iter().all(|i| {
|
||||
let brush = matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some());
|
||||
!i.visible() || (i.opacity() >= 1.0 && i.effects().is_empty() && !brush && paint(i.paint()))
|
||||
});
|
||||
let member =
|
||||
|c: &std::sync::Arc<Node>| !c.visible || (c.opacity >= 1.0 && c.extras().mask.as_deref().is_none_or(|m| m.disabled) && paints_opaque(c));
|
||||
n.appearance.effects.is_empty()
|
||||
&& items
|
||||
&& match &n.kind {
|
||||
NodeKind::Path { .. } => true,
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(member),
|
||||
NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, NodeId, Symbol, SymbolOverride};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// An instance of a dynamic symbol whose art holds an instance of the same symbol with the same
|
||||
/// appearance changes ends the search for brushed strokes where the art comes round again,
|
||||
/// after a few objects, instead of recursing until the stack runs out.
|
||||
#[test]
|
||||
fn a_dynamic_symbol_holding_itself_ends_the_brush_search() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let instance = || {
|
||||
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Loop".into(), xf: Affine::translate((40.0, 0.0)) });
|
||||
n.extras_mut().symbol_overrides = vec![SymbolOverride { path: vec![0], opacity: Some(0.5), ..Default::default() }];
|
||||
n
|
||||
};
|
||||
let square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
let art = Node::group(NodeId(0), vec![Arc::new(square), Arc::new(instance())]);
|
||||
d.symbols.push(Symbol { dynamic: true, name: "Loop".into(), art: Arc::new(art) });
|
||||
let mut search = BrushSearch::new(&d, &[]);
|
||||
assert!(!search.node(&instance(), true, 0));
|
||||
assert!(!search.exhausted);
|
||||
assert!(MAX_KNOCKOUT_MASK_OBJECTS - search.left < 10, "{} objects", MAX_KNOCKOUT_MASK_OBJECTS - search.left);
|
||||
}
|
||||
|
||||
/// A group of 1,000 instances of a dynamic symbol whose art holds 100 squares, each instance
|
||||
/// with appearance changes of its own: copying and looking through every instance's art would
|
||||
/// take about 203,000 objects, and the search stops when [`MAX_KNOCKOUT_MASK_OBJECTS`] are
|
||||
/// used, counting as a brush. With the same changes on every instance, the art is copied
|
||||
/// once.
|
||||
#[test]
|
||||
fn the_brush_search_stops_within_its_object_limit() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let square =
|
||||
Arc::new(Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::None, Paint::None, 0.0)));
|
||||
d.symbols.push(Symbol { dynamic: true, name: "Dots".into(), art: Arc::new(Node::group(NodeId(0), vec![square; 100])) });
|
||||
let group = |distinct: bool| {
|
||||
let instances = (0..1000u32)
|
||||
.map(|i| {
|
||||
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Dots".into(), xf: Affine::IDENTITY });
|
||||
let path = if distinct { vec![0, i] } else { vec![0] };
|
||||
n.extras_mut().symbol_overrides = vec![SymbolOverride { path, opacity: Some(0.5), ..Default::default() }];
|
||||
Arc::new(n)
|
||||
})
|
||||
.collect();
|
||||
Node::group(NodeId(0), instances)
|
||||
};
|
||||
let mut search = BrushSearch::new(&d, &[]);
|
||||
assert!(search.node(&group(true), true, 0));
|
||||
assert!(search.exhausted);
|
||||
assert_eq!(search.left, 0);
|
||||
let mut search = BrushSearch::new(&d, &[]);
|
||||
assert!(!search.node(&group(false), true, 0));
|
||||
assert!(!search.exhausted);
|
||||
// The group, the 1,000 instances, and one copy of the art counted and looked through.
|
||||
assert_eq!(MAX_KNOCKOUT_MASK_OBJECTS - search.left, 1 + 1000 + 101 + 101);
|
||||
}
|
||||
|
||||
/// A placed file read as one square with a 3 pt brushed stroke.
|
||||
fn brushed_art() -> (Document, Node, Rect) {
|
||||
let mut d = Document::new(40.0, 20.0);
|
||||
d.layers.clear();
|
||||
let mut st = StrokeLayer::new(Paint::solid(vectorcraft_color::Color::BLACK), 3.0);
|
||||
st.brush = Some("3 pt. Round".into());
|
||||
let mut square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
square.appearance.items.push(AppearanceItem::Stroke(st));
|
||||
let art = Node::group(NodeId(0), vec![Arc::new(square)]);
|
||||
(d, art, Rect::new(0.0, 0.0, 40.0, 20.0))
|
||||
}
|
||||
|
||||
/// A placed document is drawn as the art read from its file, and the brushed strokes in that
|
||||
/// art count.
|
||||
#[test]
|
||||
fn brushed_strokes_in_a_placed_documents_art_count() {
|
||||
let d = Document::new(200.0, 200.0);
|
||||
let placed = vectorcraft_doc::PlacedDocument {
|
||||
link: vectorcraft_doc::LinkInfo::new("/art/placed.vectorcraft"),
|
||||
key: "placed-1".into(),
|
||||
bounding: false,
|
||||
page_box: vectorcraft_doc::placed_document::PageBox::Crop,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
xf: Affine::IDENTITY,
|
||||
placement: Default::default(),
|
||||
};
|
||||
// The art the export reads for it before drawing (`Document::with_placed_art`).
|
||||
vectorcraft_doc::placed_document::prime(&placed, Arc::new(vec![]), brushed_art());
|
||||
let n = Node::new(NodeId(0), NodeKind::PlacedDocument(Box::new(placed)));
|
||||
let brushes = vectorcraft_brush::library(&d);
|
||||
assert!(BrushSearch::new(&d, &brushes).node(&n, true, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +387,21 @@ impl<'p> Builder<'p> {
|
||||
only = fold_stop_opacity(&only, &m).unwrap_or_else(|| only.with_mask(Some(Box::new(m))));
|
||||
}
|
||||
Some(only)
|
||||
} else if let [only] = children.as_slice()
|
||||
&& matches!(only.kind, NodeKind::Group { clip: false, .. })
|
||||
&& only.knockout == Knockout::On
|
||||
&& only.opacity >= 0.999
|
||||
&& only.blend == BlendMode::Normal
|
||||
&& !only.isolate
|
||||
&& only.extras().mask.is_none()
|
||||
&& mask.is_none()
|
||||
&& !knockout
|
||||
{
|
||||
// A knockout group drawn in a form that carries its opacity, blend mode or
|
||||
// isolation: one knockout group with them.
|
||||
let mut only = (**only).clone();
|
||||
(only.opacity, only.blend, only.isolate) = (opacity, blend, isolate);
|
||||
Some(only)
|
||||
} else {
|
||||
let mut g = Node::group(self.id(), children);
|
||||
(g.opacity, g.blend, g.isolate) = (opacity, blend, isolate);
|
||||
|
||||
@@ -81,6 +81,11 @@ impl<'a> Surface<'a> {
|
||||
self.group(|s| s.push_isolated());
|
||||
}
|
||||
|
||||
/// A transparency group with `options` (isolated, knockout), as [`Self::push_isolated`].
|
||||
pub fn push_group(&mut self, options: krilla::blend::GroupOptions) {
|
||||
self.group(|s| s.push_group(options));
|
||||
}
|
||||
|
||||
/// The mask's stream is already in [`Self::group_transform`]'s space, like the group's art.
|
||||
pub fn push_mask(&mut self, mask: Mask) {
|
||||
self.group(|s| s.push_mask(mask));
|
||||
|
||||
@@ -271,6 +271,71 @@ fn a_scaled_nine_slice_instance_keeps_its_corners() {
|
||||
assert!(close(boxes[2], Rect::new(105.0, 85.0, 135.0, 105.0), 0.01), "{boxes:?}");
|
||||
}
|
||||
|
||||
/// An instance of symbol `name` placed by `xf`, its fill red at `opacity` (shown when `visible`).
|
||||
fn stained_instance(name: &str, xf: Affine, opacity: f32, visible: bool) -> Node {
|
||||
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf });
|
||||
n.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
|
||||
n.appearance.items.retain(AppearanceItem::is_fill);
|
||||
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
|
||||
(f.opacity, f.visible) = (opacity, visible);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// The fill colors of `d`'s leaves, in paint order.
|
||||
fn leaf_fills(d: &Document) -> Vec<String> {
|
||||
leaves(d).iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| c.to_hex()).collect()
|
||||
}
|
||||
|
||||
/// A stained instance is written in the colors the canvas draws it in
|
||||
/// ([`vectorcraft_brush::instance_art`]): on its own, with a Transform effect that moves it, inside
|
||||
/// a group, after a dynamic symbol's own fill change, and placed through 9-slice guides. A hidden
|
||||
/// fill or one at 0% opacity leaves the art's colors as they are.
|
||||
#[test]
|
||||
fn a_stained_instance_keeps_its_stain() {
|
||||
let blue = Color::rgb8(30, 90, 220);
|
||||
let art = || Node::group(NodeId(0), vec![Arc::new(rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), blue))]);
|
||||
let canvas = |n: &Node| leaf_fills_of(&vectorcraft_brush::instance_art(&art(), n));
|
||||
fn leaf_fills_of(n: &Node) -> Vec<String> {
|
||||
let mut d = doc(10.0, 10.0);
|
||||
add(&mut d, n.clone());
|
||||
leaf_fills(&d)
|
||||
}
|
||||
let write = |n: Node, nine_slice: bool| {
|
||||
let mut d = doc(300.0, 200.0);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "Panel".into(), art: Arc::new(art()) });
|
||||
if nine_slice {
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
}
|
||||
add(&mut d, n);
|
||||
leaf_fills(&roundtrip(&d))
|
||||
};
|
||||
let half = stained_instance("Panel", Affine::translate((20.0, 20.0)), 0.5, true);
|
||||
let want = canvas(&half);
|
||||
assert_ne!(want, [blue.to_hex()], "the stain changes the color");
|
||||
assert_eq!(write(half.clone(), false), want, "on its own");
|
||||
let mut moved = half.clone();
|
||||
moved.appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "distort.transform".into(),
|
||||
params: serde_json::json!({"moveH": 30.0}),
|
||||
visible: true,
|
||||
});
|
||||
assert_eq!(write(moved, false), want, "with a geometry effect");
|
||||
assert_eq!(write(Node::group(NodeId(0), vec![Arc::new(half.clone())]), false), want, "in a group");
|
||||
let mut scaled = half.clone();
|
||||
scaled.kind = NodeKind::SymbolInstance { symbol: "Panel".into(), xf: Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5) };
|
||||
assert_eq!(write(scaled, true), want, "through 9-slice guides");
|
||||
let mut changed = half.clone();
|
||||
changed.extras_mut().symbol_overrides =
|
||||
vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(Paint::solid(Color::rgb(0.0, 1.0, 0.0))), ..Default::default() }];
|
||||
let changed_want = canvas(&changed);
|
||||
assert_ne!(changed_want, want, "the part's own fill is stained");
|
||||
assert_eq!(write(changed, false), changed_want, "after a dynamic symbol's change");
|
||||
for (opacity, visible) in [(0.0, true), (0.5, false)] {
|
||||
assert_eq!(write(stained_instance("Panel", Affine::IDENTITY, opacity, visible), false), [blue.to_hex()], "unstained");
|
||||
}
|
||||
}
|
||||
|
||||
/// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES`
|
||||
/// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through
|
||||
/// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user